# Чат с агентом МойЧата — инструкция для агента-разработчика

Этот файл самодостаточен: по нему можно собрать чат с агентом «как в МойЧате» в новом проекте,
не имея доступа к исходному репозиторию. Живое демо — `index.html` рядом (вкладка «Демо»).

**Что получится:** экран чата с агентом — лента (реплики пользователя с цитатами, ответы агента в Markdown,
строка «N шагов · длительность · модель · время», действия при наведении), блок «Работает» во время хода,
всплывашка над выделенным текстом «Цитировать · Делай · Не понял», поле ввода, где цитаты встают
блоками туда, где курсор, вложения, «Поделиться» в другую ветку, кнопки поля ввода.

## 0. Порядок работы

1. Создать проект: Vite + React 19 + TypeScript + Tailwind 4 + shadcn (пресет с `@base-ui/react`).
2. Поставить зависимости (раздел 1), завести токены темы (раздел 2).
3. Добавить shadcn-компоненты `button popover command` (их код — в разделе 6, если CLI недоступен).
4. Перенести модули из раздела 6 файлами по указанным путям, алиас `@/` → `src/`.
5. Собрать экран чата по схеме из раздела 5 (выдержки `AgentChatView.tsx` — там всё, что касается ленты,
   выделения, кнопок и «Поделиться»).
6. Подключить сервер по контракту из раздела 3 (или заглушку с теми же ответами и событиями).
7. Проверить по чек-листу из раздела 7.

## 1. Библиотеки

```bash
npm create vite@latest chat -- --template react-ts
npm i react@^19.2 react-dom@^19.2 react-markdown@^10.1 remark-gfm@^4.0 react-router-dom@^7 lucide-react@1.24.0 sonner@^2.0 \
  @base-ui/react@^1.6 cmdk@^1.1 class-variance-authority@^0.7 clsx@^2.1 tailwind-merge@^3.6 \
  @fontsource-variable/geist@^5.3 tw-animate-css@^1.4
npm i -D tailwindcss@^4.3 @tailwindcss/vite@^4.3 @vitejs/plugin-react typescript @types/react @types/react-dom
npx shadcn@latest init   # пресет на Base UI, иконки lucide
npx shadcn@latest add button popover command dialog tooltip checkbox dropdown-menu input-group input textarea
```

| Пакет | Где нужен |
|---|---|
| `react-markdown` + `remark-gfm` | `Markdown.tsx` — ответы агента, таблицы, ```` ```details ```` |
| `lucide-react` | все иконки (Paperclip, Mic, AtSign, Zap, Brain, ArrowUp, Quote, Play, HelpCircle, Share2, Wrench…) |
| `@base-ui/react` | основа shadcn-компонентов (кнопки, поповер) |
| `cmdk` | список веток с поиском в «Поделиться» (`command.tsx`) |
| `class-variance-authority`, `clsx`, `tailwind-merge` | варианты кнопок и `cn()` |
| `sonner` | всплывающие уведомления (`toast`) |

Сборка — только `NODE_ENV=production npx vite build` (с development Vite кладёт отладочный React, он в разы медленнее).

## 2. Тема

Тёмная тема — класс `dark` на `<html>`. Токены shadcn (Tailwind 4, `src/index.css`):

```css
@import "tailwindcss";
@import "tw-animate-css";
@import "@fontsource-variable/geist";
@custom-variant dark (&:is(.dark *));

@theme inline {
  --font-sans: "Geist Variable", system-ui, sans-serif;
  --color-background: var(--background); --color-foreground: var(--foreground);
  --color-card: var(--card); --color-card-foreground: var(--card-foreground);
  --color-popover: var(--popover); --color-popover-foreground: var(--popover-foreground);
  --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground);
  --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground);
  --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground);
  --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground);
  --color-destructive: var(--destructive);
  --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring);
  --radius-sm: calc(var(--radius) * 0.6); --radius-md: calc(var(--radius) * 0.8);
  --radius-lg: var(--radius); --radius-xl: calc(var(--radius) * 1.4); --radius-2xl: calc(var(--radius) * 1.8);
}

.dark {
  --radius: 0.625rem;
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --card: oklch(0.205 0 0);
  --card-foreground: oklch(0.985 0 0);
  --popover: oklch(0.205 0 0);
  --popover-foreground: oklch(0.985 0 0);
  --primary: oklch(0.7 0.16 45);          /* акцент: оранжевый, как в демо; в МойЧате задаётся пресетом темы */
  --primary-foreground: oklch(0.985 0 0);
  --secondary: oklch(0.269 0 0);
  --secondary-foreground: oklch(0.985 0 0);
  --muted: oklch(0.269 0 0);
  --muted-foreground: oklch(0.708 0 0);
  --accent: oklch(0.269 0 0);
  --accent-foreground: oklch(0.985 0 0);
  --destructive: oklch(0.704 0.191 22.216);
  --border: oklch(1 0 0 / 10%);
  --input: oklch(1 0 0 / 15%);
  --ring: oklch(0.62 0.19 259);           /* синяя рамка поля ввода в фокусе */
}

/* Базовый слой shadcn — без него рамки в Tailwind 4 берут цвет текста (белые). */
@layer base {
  * { @apply border-border outline-ring/50; }
  body { @apply bg-background text-foreground; }
}
```

## 3. Контракт сервера

Все ответы — `{ ok: true, data }` или `{ ok: false, error }`. Авторизация — `Authorization: Bearer <token>`.

| Запрос | Тело | Ответ `data` |
|---|---|---|
| `GET /api/agent-chats/:id?before=<msgId>` | — | `{ chat, messages: AgentMessage[], hasMore, live: LiveRun \| null }` |
| `POST /api/agent-chats/:id/messages` | `{ text, fileIds: string[] }` | `{ message: AgentMessage, queued, injected, live, chat }` |
| `POST /api/agent-chats/:id/stop` | — | `{}`; 409 — агент не работает |
| `POST /api/files?projectId=<id>` | тело файла, заголовки `X-File-Name` (encodeURIComponent), `X-File-Type` | `FileDto` |

`text` реплики — Markdown-строка; цитаты в ней — блоки строк `> …`, первая строка блока `> [Opus · 12.10 12:44]` —
подпись источника (`quoteBlock` в `drafts.ts`). Агент получает реплику ровно в таком виде.

Если агент работает, новая реплика не ждёт конца хода: сервер досылает её в идущий ход (`injected`),
иначе ставит в очередь (`queued`).

**Поток событий** — `GET /api/events` (Server-Sent Events, `event: <имя>\ndata: <json>\n\n`):

| Событие | `data` | Что делает экран |
|---|---|---|
| `agent:message` | `{ chatId, message }` | добавить реплику в ленту |
| `agent:start` | `{ chatId, projectId, live: LiveRun }` | показать блок «Работает», снять «в очереди» |
| `agent:tool` | `{ chatId, runId, tool: { name, summary }, toolCount }` | добавить шаг в `live.tools` |
| `agent:delta` | `{ chatId, runId, delta, total }` | дописать `delta` к `live.text` (если длина не сходится с `total - delta.length` — перечитать чат) |
| `agent:stopping` | `{ chatId }` | «Останавливается» |
| `agent:done` | `{ chatId, runId, status, message, chat }` | убрать блок «Работает», добавить ответ |
| `agent:injected` | `{ chatId, ids, state: 'pending' \| 'taken' }` | пометка «дослано» у реплик |

## 4. Типы

```ts
export interface ToolStep { name: string; summary: string; error?: boolean; at?: number }

export interface FileDto {
  id: string; name: string; mime: string; size: number; caption: string
  inBackpack: boolean; isImage: boolean; url: string
  projectId: string | null; threadId: string | null; createdAt: string
}

export interface AgentMessage {
  id: string; chatId: string; role: 'user' | 'assistant' | 'system'
  content: string; files: FileDto[]
  meta: {
    tools?: ToolStep[]; subagents?: number; status?: string; durationMs?: number
    model?: string | null; error?: string; summary?: boolean
    queued?: boolean; injected?: 'pending' | 'taken'; cancelled?: boolean
    models?: string[]; thinking?: unknown; usageComplete?: boolean; auto?: boolean; kind?: string
  }
  runId: string | null; createdAt: string
}

export interface LiveRun {
  runId: string; purpose: 'chat' | 'summary' | 'auto' | 'close'
  text: string; tools: ToolStep[]; toolCount: number; subagents: number
  startedAt: number; model: string | null; stopping: boolean
  engine?: 'claude' | 'codex'
  /** Состояние шага у шлюза модели (необязательно): фаза, с какого момента, последнее событие. */
  gatewayStatus?: {
    phase: 'unknown' | 'waiting' | 'thinking' | 'compacting' | 'answering' | 'finishing'
    since: number; lastEventAt: number | null; gatewayAt: number | null; connected: boolean
  }
}
```

## 5. Как устроен экран (AgentChatView)

```
<div flex-col h-full>
  <лента ref=scroller>                       ← сообщения: MessageItem → MessageBody / AssistantMessage
     {live && <LiveBlock/>}                  ← «Работает · 12 с · шаг 3: Edit — …  [Стоп]» + печатающийся текст
  </лента>
  {sel && <всплывашка над выделением/>}      ← QUOTE_ACTIONS: Цитировать / Делай / Не понял → injectQuote
  <низ>
     {picked && <панель «Поделиться» + ShareMenu/>}
     <Composer storageKey=`draft:agent:${chatId}` onSend actions={кнопки}/>
  </низ>
</div>
```

Ключевые решения:
- **Цитата — блок в тексте, не плашка.** `injectQuote` шлёт событие `quote-inject`; `Composer` режет активный
  текстовый блок по курсору на «до» и «после», между ними — карточка цитаты; `note` («делай», «не понял»)
  становится первым словом блока «после». Поле ввода закрыто — цитата дописывается в черновик в localStorage.
- **Источник цитаты** — атрибут `data-quote-src` у сообщения («Opus · 12.10 12:44»); выделение через два
  сообщения даёт «A → B».
- **Мышь** — всплывашка над выделением (`fixed`, `-translate-x-1/2 -translate-y-full`); **телефон** (`pointer: coarse`) —
  полоса над полем ввода, иначе её закрывает системное меню Chrome. `preventDefault` на `mousedown`, чтобы выделение
  не слетело до клика.
- **«Поделиться»**: `picked: Set<id> | null`; в режиме отметки у сообщений галочки слева, клик — отметить;
  `ShareMenu` вставляет отмеченные цитатами в черновик выбранной ветки и открывает её.
- **Кнопки поля ввода** передаются в `Composer` через `actions`: «Короткие ответы» (меню, шлёт
  «Отвечай коротко, до N слов»), «Умный режим» (переключатель: следующий ход с максимумом размышления),
  «Зафиксировать работу в git», «Как дела?», «Стоп» во время хода.
- Диктовку (`useLiveDictation`) можно заменить заглушкой — см. раздел 6.

## 6. Исходники

Пути — относительно `src/`. Код взят из рабочего проекта без изменений, кроме пометок «ЗАГЛУШКА».

### `src/lib/types.ts`
Те же типы, что в разделе 4.
```ts

export interface ToolStep { name: string; summary: string; error?: boolean; at?: number }

export interface FileDto {
  id: string; name: string; mime: string; size: number; caption: string
  inBackpack: boolean; isImage: boolean; url: string
  projectId: string | null; threadId: string | null; createdAt: string
}

export interface AgentMessage {
  id: string; chatId: string; role: 'user' | 'assistant' | 'system'
  content: string; files: FileDto[]
  meta: {
    tools?: ToolStep[]; subagents?: number; status?: string; durationMs?: number
    model?: string | null; error?: string; summary?: boolean
    queued?: boolean; injected?: 'pending' | 'taken'; cancelled?: boolean
    models?: string[]; thinking?: unknown; usageComplete?: boolean; auto?: boolean; kind?: string
  }
  runId: string | null; createdAt: string
}

export interface LiveRun {
  runId: string; purpose: 'chat' | 'summary' | 'auto' | 'close'
  text: string; tools: ToolStep[]; toolCount: number; subagents: number
  startedAt: number; model: string | null; stopping: boolean
  engine?: 'claude' | 'codex'
  /** Состояние шага у шлюза модели (необязательно): фаза, с какого момента, последнее событие. */
  gatewayStatus?: {
    phase: 'unknown' | 'waiting' | 'thinking' | 'compacting' | 'answering' | 'finishing'
    since: number; lastEventAt: number | null; gatewayAt: number | null; connected: boolean
  }
}
```

### `src/lib/utils.ts`

```ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
```

### `src/lib/api.ts`
`syncClock` — поправка часов по заголовку сервера; без него просто убрать импорт и вызов.
```ts
import { syncClock } from '@/lib/clock'

export interface ApiEnvelope<T = unknown> {
  ok: boolean
  data?: T
  error?: string
  details?: string | null
  [k: string]: unknown
}

export const TOKEN_KEY = 'bp_token'
export const USER_KEY = 'bp_user'

export class ApiError extends Error {
  status: number
  constructor(message: string, status: number) {
    super(message)
    this.status = status
  }
}

function authHeader(): Record<string, string> {
  const token = localStorage.getItem(TOKEN_KEY)
  return token ? { Authorization: `Bearer ${token}` } : {}
}

async function request<T>(path: string, init: RequestInit = {}): Promise<ApiEnvelope<T>> {
  const res = await fetch(path, {
    ...init,
    headers: { 'Content-Type': 'application/json', ...authHeader(), ...(init.headers || {}) },
  })
  syncClock(Number(res.headers.get('X-Server-Time')) || undefined)

  if (res.status === 401 && !path.startsWith('/api/auth/login')) {
    localStorage.removeItem(TOKEN_KEY)
    localStorage.removeItem(USER_KEY)
    if (window.location.pathname !== '/login') window.location.href = '/login'
    throw new ApiError('Сессия завершена', 401)
  }

  let body: ApiEnvelope<T> | null = null
  try {
    body = (await res.json()) as ApiEnvelope<T>
  } catch {
    body = null
  }
  if (!res.ok || (body && body.ok === false)) {
    throw new ApiError(body?.error || `Ошибка запроса (${res.status})`, res.status)
  }
  return body || ({ ok: true } as ApiEnvelope<T>)
}

export const api = {
  get: <T = unknown>(path: string) => request<T>(path, { method: 'GET' }),
  post: <T = unknown>(path: string, payload?: unknown) =>
    request<T>(path, { method: 'POST', body: payload === undefined ? undefined : JSON.stringify(payload) }),
  put: <T = unknown>(path: string, payload?: unknown) =>
    request<T>(path, { method: 'PUT', body: payload === undefined ? undefined : JSON.stringify(payload) }),
  patch: <T = unknown>(path: string, payload?: unknown) =>
    request<T>(path, { method: 'PATCH', body: payload === undefined ? undefined : JSON.stringify(payload) }),
  delete: <T = unknown>(path: string) => request<T>(path, { method: 'DELETE' }),
}
```

### `src/lib/drafts.ts`

```ts
/**
 * Цитата в поле ввода: вставляется блоком «> …» прямо в текст, где стоит курсор, —
 * пишешь ответ под ней, цитируешь следующее, отвечаешь под ним; агент видит тот же порядок.
 * source — чья и когда («Opus · 23.09 08:46»): первой строкой блока в квадратных скобках,
 * чтобы агент видел, откуда цитата и где кончается одна и начинается другая.
 */
export interface QuoteItem {
  text: string
  source?: string
}

/** Блок цитаты: «> [источник]\n> текст». */
export function quoteBlock(q: QuoteItem) {
  return [...(q.source ? [`[${q.source}]`] : []), ...q.text.split('\n')].map((l) => `> ${l}`).join('\n')
}

export function injectQuote(key: string, text: string, source?: string, note?: string) {
  const clean = text.trim()
  if (!clean) return
  const item: QuoteItem = { text: clean, source }
  // note — готовая реплика сразу под цитатой («делай», «не понял»), курсор встаёт после неё.
  const detail = { key, item, note, handled: false }
  window.dispatchEvent(new CustomEvent('quote-inject', { detail }))
  // Поле ввода не открыто — цитата в конец черновика, появится, когда чат откроют.
  if (!detail.handled) {
    const cur = localStorage.getItem(key) || ''
    localStorage.setItem(key, `${cur.trim() ? `${cur.trimEnd()}\n\n` : ''}${quoteBlock(item)}\n\n${note ? `${note} ` : ''}`)
  }
}

/** Блок поля ввода: текст или карточка цитаты. Между карточками всегда текст (может быть пустым). */
export type Segment = { kind: 'text'; id: string; value: string } | { kind: 'quote'; id: string; item: QuoteItem }

let segId = 0
const sid = (p: string) => `${p}${++segId}_${Date.now().toString(36)}`

/** Строка черновика → блоки: подряд идущие строки «> …» — одна цитата, «> [кто · когда]» первой — подпись. */
export function splitSegments(draft: string): Segment[] {
  const out: Segment[] = []
  let text: string[] = []
  let quote: string[] | null = null
  const flushText = () => { out.push({ kind: 'text', id: sid('t'), value: text.join('\n').replace(/^\n+|\n+$/g, '') }); text = [] }
  const flushQuote = () => {
    if (!quote) return
    const m = /^\[(.+)\]$/.exec(quote[0] || '')
    const body = (m ? quote.slice(1) : quote).join('\n').trim()
    if (body) {
      flushText()
      out.push({ kind: 'quote', id: sid('q'), item: { text: body, source: m?.[1] } })
    } else text.push(...quote.map((l) => `> ${l}`))
    quote = null
  }
  for (const line of draft.split('\n')) {
    const q = /^> ?(.*)$/.exec(line)
    if (q) (quote ??= []).push(q[1])
    else { flushQuote(); text.push(line) }
  }
  flushQuote()
  flushText()
  return out
}

/** Блоки → одна реплика в том же порядке. */
export function joinSegments(segs: Segment[]) {
  return segs.map((x) => (x.kind === 'text' ? x.value.trim() : quoteBlock(x.item))).filter(Boolean).join('\n\n')
}

/** Черновик с цитатами старого вида (плашки в `${key}:quotes`) — переносим в текст и забываем. */
export function readDraft(key: string): string {
  const text = localStorage.getItem(key) || ''
  let old: QuoteItem[] = []
  try {
    const v = JSON.parse(localStorage.getItem(`${key}:quotes`) || '[]')
    if (Array.isArray(v)) old = v.map((x) => (typeof x === 'string' ? { text: x } : x)).filter((x) => x && typeof x.text === 'string')
  } catch { /* битое — пропускаем */ }
  if (!old.length) return text
  localStorage.removeItem(`${key}:quotes`)
  const merged = [...old.map(quoteBlock), text].filter(Boolean).join('\n\n')
  localStorage.setItem(key, merged)
  return merged
}

/** Подпись источника: «Opus · 23.09 08:46» (время МСК, дата всегда — цитата может пережить свёртку сессии). */
export function quoteSource(author: string, at: string | number) {
  const d = new Date(at)
  const date = d.toLocaleDateString('ru-RU', { timeZone: 'Europe/Moscow', day: '2-digit', month: '2-digit' })
  const time = d.toLocaleTimeString('ru-RU', { timeZone: 'Europe/Moscow', hour: '2-digit', minute: '2-digit' })
  return `${author} · ${date} ${time}`
}

// Вставка текста в черновик чата («→ агенту», «→ клиенту», «обсудить с агентом»).
// Открытый поле ввода с этим ключом забирает текст сразу; если чат ещё не открыт —
// текст ложится в его черновик в localStorage и появится, как только поле смонтируется.
// Так текст не теряется ни при переключении вкладок на телефоне, ни при медленной загрузке.
export function injectDraft(key: string, text: string) {
  const detail = { key, text, handled: false }
  window.dispatchEvent(new CustomEvent('draft-inject', { detail }))
  if (!detail.handled) {
    const cur = localStorage.getItem(key) || ''
    localStorage.setItem(key, cur.trim() ? `${cur.trimEnd()}\n\n${text}` : text)
  }
}
```

### `src/lib/format.ts`
`serverNow()` можно заменить на `Date.now()`.
```ts
import { serverNow } from '@/lib/clock'

const TZ = 'Europe/Moscow'

export function fmtTime(iso: string | number | null | undefined): string {
  if (!iso) return ''
  const d = new Date(iso)
  const now = new Date(serverNow())
  const sameDay = d.toLocaleDateString('ru-RU', { timeZone: TZ }) === now.toLocaleDateString('ru-RU', { timeZone: TZ })
  if (sameDay) return d.toLocaleTimeString('ru-RU', { timeZone: TZ, hour: '2-digit', minute: '2-digit' })
  const y = new Date(now.getTime() - 86400000)
  if (d.toLocaleDateString('ru-RU', { timeZone: TZ }) === y.toLocaleDateString('ru-RU', { timeZone: TZ })) {
    return `вчера ${d.toLocaleTimeString('ru-RU', { timeZone: TZ, hour: '2-digit', minute: '2-digit' })}`
  }
  return d.toLocaleString('ru-RU', { timeZone: TZ, day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })
}

export function fmtAgo(iso: string | number | null | undefined): string {
  if (!iso) return '—'
  const ms = serverNow() - new Date(iso).getTime()
  const m = Math.floor(ms / 60000)
  if (m < 1) return 'только что'
  if (m < 60) return `${m} мин назад`
  const h = Math.floor(m / 60)
  if (h < 24) return `${h} ч назад`
  const d = Math.floor(h / 24)
  if (d < 30) return `${d} дн назад`
  return new Date(iso).toLocaleDateString('ru-RU', { timeZone: TZ, day: '2-digit', month: 'short' })
}

export function fmtDuration(ms: number): string {
  const s = Math.max(0, Math.floor(ms / 1000))
  if (s < 60) return `${s} с`
  const m = Math.floor(s / 60)
  if (m < 60) return `${m} мин ${s % 60} с`
  return `${Math.floor(m / 60)} ч ${m % 60} мин`
}

export function fmtSize(n: number): string {
  if (n < 1024) return `${n} Б`
  if (n < 1024 * 1024) return `${Math.round(n / 1024)} КБ`
  return `${(n / 1024 / 1024).toFixed(1)} МБ`
}

export function fmtTokens(n: number): string {
  if (!n) return '0'
  if (n < 1000) return String(n)
  return `${Math.round(n / 1000)}k`
}

export function initials(name: string): string {
  const parts = (name || '').trim().split(/\s+/).filter(Boolean)
  const s = parts.length >= 2 ? parts[0][0] + parts[1][0] : (name || '?').slice(0, 2)
  return s.toUpperCase()
}

export const STATUS_LABEL: Record<string, string> = {
  active: 'В работе',
  paused: 'Пауза',
  done: 'Готов',
  archive: 'Архив',
}

export const PROJECT_COLORS = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#8b5cf6', '#14b8a6', '#64748b', '#a16207']
```

### `src/lib/upload.ts`

```ts
import { TOKEN_KEY } from '@/lib/api'
import type { FileDto } from '@/lib/types'

export interface UploadTarget {
  projectId?: string
  threadId?: string
  backpack?: boolean
  caption?: string
}

const BIG_FROM = 16 * 1024 * 1024
const BIG_MAX = 3 * 1024 * 1024 * 1024
const PARALLEL = 3
const RETRIES = 5

/** Файл до 16 МБ — одним телом; больше (до 3 ГБ, только владелец) — кусками с докачкой. */
export function uploadFile(file: File, target: UploadTarget, onProgress?: (p: number) => void): Promise<FileDto> {
  if (file.size > BIG_MAX) return Promise.reject(new Error('Файл больше 3 ГБ'))
  if (file.size <= BIG_FROM) return uploadWhole(file, target, onProgress)
  // Кусками может только владелец; клиент получит 403 и пойдёт одним телом (у него потолок 25 МБ).
  return uploadChunked(file, target, onProgress).catch((e) => {
    if ((e as { status?: number }).status === 403) return uploadWhole(file, target, onProgress)
    throw e
  })
}

type Resp<T> = { ok: boolean; data: T; error?: string }

/** XHR, а не fetch: у fetch нет прогресса отправки. */
function send<T>(method: string, url: string, body: Blob | string, onProgress?: (loaded: number) => void): Promise<T> {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()
    xhr.open(method, url)
    const token = localStorage.getItem(TOKEN_KEY)
    if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
    xhr.setRequestHeader('Content-Type', typeof body === 'string' ? 'application/json' : 'application/octet-stream')
    if (onProgress) xhr.upload.onprogress = (e) => onProgress(e.loaded)
    xhr.onload = () => {
      let r: Resp<T> | null = null
      try { r = JSON.parse(xhr.responseText) } catch { /* не JSON — прокси */ }
      if (xhr.status >= 200 && xhr.status < 300 && r?.ok) resolve(r.data)
      else reject(Object.assign(new Error(r?.error || `Ошибка загрузки (${xhr.status})`), { status: xhr.status }))
    }
    xhr.onerror = () => reject(Object.assign(new Error('Сеть оборвалась при загрузке'), { status: 0 }))
    xhr.send(body)
  })
}

async function uploadChunked(file: File, target: UploadTarget, onProgress?: (p: number) => void): Promise<FileDto> {
  // Ключ от файла и цели: бросил тот же файл туда же ещё раз — сервер вернёт принятые куски, докачка с места.
  const raw = `${file.name}|${file.size}|${file.lastModified}|${target.projectId || ''}|${target.threadId || ''}`
  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(raw))
  const key = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
  const begin = await send<{ id: string; chunkSize: number; total: number; have: number[] }>('POST', '/api/files/upload/begin', JSON.stringify({
    key, name: file.name || 'file', size: file.size, mime: file.type || 'application/octet-stream',
    projectId: target.projectId, threadId: target.threadId, backpack: !!target.backpack, caption: target.caption,
  }))
  const { id, chunkSize, total } = begin
  const have = new Set(begin.have)
  const todo = Array.from({ length: total }, (_, i) => i).filter((i) => !have.has(i))
  const sizeOf = (i: number) => Math.min(chunkSize, file.size - i * chunkSize)
  let done = [...have].reduce((s, i) => s + sizeOf(i), 0)
  const inFlight = new Map<number, number>()
  const report = () => onProgress?.((done + [...inFlight.values()].reduce((s, x) => s + x, 0)) / file.size)
  report()

  const putChunk = async (i: number) => {
    const blob = file.slice(i * chunkSize, i * chunkSize + sizeOf(i))
    for (let attempt = 1; ; attempt++) {
      try {
        await send('PUT', `/api/files/upload/${id}/chunk?i=${i}`, blob, (loaded) => { inFlight.set(i, loaded); report() })
        inFlight.delete(i)
        done += sizeOf(i)
        report()
        return
      } catch (e) {
        inFlight.delete(i)
        const status = (e as { status?: number }).status ?? 0
        // 4xx (кроме 408/429) повтором не лечится; сеть и 5xx — повторяем с паузой.
        if (attempt >= RETRIES || (status >= 400 && status < 500 && status !== 408 && status !== 429)) throw e
        await new Promise((r) => setTimeout(r, 1000 * 2 ** (attempt - 1)))
      }
    }
  }
  let next = 0
  await Promise.all(Array.from({ length: Math.min(PARALLEL, todo.length) }, async () => {
    while (next < todo.length) await putChunk(todo[next++])
  }))
  return send<FileDto>('POST', `/api/files/upload/${id}/finish`, '{}')
}

/** Загрузка одним телом с прогрессом. */
function uploadWhole(file: File, target: UploadTarget, onProgress?: (p: number) => void): Promise<FileDto> {
  return new Promise((resolve, reject) => {
    const q = new URLSearchParams()
    if (target.threadId) q.set('threadId', target.threadId)
    if (target.projectId) q.set('projectId', target.projectId)
    if (target.backpack) q.set('backpack', '1')
    const xhr = new XMLHttpRequest()
    xhr.open('POST', `/api/files?${q}`)
    const token = localStorage.getItem(TOKEN_KEY)
    if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
    xhr.setRequestHeader('Content-Type', 'application/octet-stream')
    xhr.setRequestHeader('X-File-Type', file.type || 'application/octet-stream')
    xhr.setRequestHeader('X-File-Name', encodeURIComponent(file.name || 'file'))
    if (target.caption) xhr.setRequestHeader('X-File-Caption', encodeURIComponent(target.caption))
    xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress?.(e.loaded / e.total) }
    xhr.onload = () => {
      try {
        const body = JSON.parse(xhr.responseText)
        if (xhr.status >= 200 && xhr.status < 300 && body.ok) resolve(body.data as FileDto)
        else reject(new Error(body.error || `Ошибка загрузки (${xhr.status})`))
      } catch {
        reject(new Error(xhr.status === 413 ? 'Файл слишком большой' : `Ошибка загрузки (${xhr.status})`))
      }
    }
    xhr.onerror = () => reject(new Error('Сеть оборвалась при загрузке'))
    xhr.send(file)
  })
}
```

### `src/components/chat/Markdown.tsx`

```tsx
import { memo, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Check, ChevronRight, Copy } from 'lucide-react'
import { cn } from '@/lib/utils'

function CodeBlock({ children, className }: { children?: React.ReactNode; className?: string }) {
  const [copied, setCopied] = useState(false)
  const text = String(children ?? '').replace(/\n$/, '')
  const lang = /language-(\w+)/.exec(className || '')?.[1]
  return (
    <div className="group/code bg-muted/60 relative my-2 overflow-hidden rounded-lg border">
      <div className="text-muted-foreground flex items-center justify-between border-b px-3 py-1 text-[11px]">
        <span>{lang || 'код'}</span>
        <button
          type="button"
          className="hover:text-foreground flex items-center gap-1 opacity-70 transition-opacity group-hover/code:opacity-100"
          onClick={() => {
            navigator.clipboard?.writeText(text).then(() => {
              setCopied(true)
              setTimeout(() => setCopied(false), 1500)
            })
          }}
        >
          {copied ? <Check className="size-3" /> : <Copy className="size-3" />}
          {copied ? 'скопировано' : 'копировать'}
        </button>
      </div>
      <pre className="overflow-x-auto p-3 text-[12.5px] leading-relaxed"><code>{text}</code></pre>
    </div>
  )
}

// Раскрывающийся блок без HTML (Markdown у нас без сырого HTML — так безопаснее): код-блок с меткой details,
// первая строка — заголовок, остальное — обычный Markdown внутри. Нужен живому плану (журнал изменений, 04.10.2026).
// <details> неуправляемый: при перерисовке плана тем же текстом сверху раскрытый журнал не схлопывается.
function DetailsBlock({ text }: { text: string }) {
  const [summary, ...rest] = text.split('\n')
  return (
    <details className="group/details bg-muted/30 my-2 rounded-lg border">
      <summary className="text-muted-foreground hover:text-foreground flex cursor-pointer list-none items-center gap-1.5 px-3 py-1.5 text-[12.5px] select-none [&::-webkit-details-marker]:hidden">
        <ChevronRight className="size-3.5 transition-transform group-open/details:rotate-90" />
        {summary.trim() || 'Подробнее'}
      </summary>
      <div className="border-t px-3 py-2">
        <Markdown text={rest.join('\n')} />
      </div>
    </details>
  )
}

export const Markdown = memo(function Markdown({ text, className }: { text: string; className?: string }) {
  return (
    <div className={cn('md-body text-sm leading-relaxed break-words', className)}>
      <ReactMarkdown
        remarkPlugins={[remarkGfm]}
        components={{
          a: ({ href, children }) => (
            <a href={href} target="_blank" rel="noreferrer noopener" className="text-primary underline underline-offset-2">
              {children}
            </a>
          ),
          code: ({ className, children }) => {
            if (/language-details\b/.test(className || '')) return <DetailsBlock text={String(children ?? '').replace(/\n$/, '')} />
            const isBlock = /language-/.test(className || '') || String(children ?? '').includes('\n')
            if (isBlock) return <CodeBlock className={className}>{children}</CodeBlock>
            return <code className="bg-muted rounded px-1 py-0.5 font-mono text-[0.9em]">{children}</code>
          },
          pre: ({ children }) => <>{children}</>,
          table: ({ children }) => (
            <div className="my-2 overflow-x-auto rounded-lg border">
              <table className="w-full border-collapse text-[13px]">{children}</table>
            </div>
          ),
          th: ({ children }) => <th className="bg-muted/60 border-b px-2.5 py-1.5 text-left font-semibold">{children}</th>,
          td: ({ children }) => <td className="border-b px-2.5 py-1.5 align-top">{children}</td>,
          ul: ({ children }) => <ul className="my-1.5 list-disc space-y-0.5 pl-5">{children}</ul>,
          ol: ({ children }) => <ol className="my-1.5 list-decimal space-y-0.5 pl-5">{children}</ol>,
          p: ({ children }) => <p className="my-1.5 first:mt-0 last:mb-0">{children}</p>,
          h1: ({ children }) => <h3 className="mt-3 mb-1.5 text-base font-semibold">{children}</h3>,
          h2: ({ children }) => <h3 className="mt-3 mb-1.5 text-base font-semibold">{children}</h3>,
          h3: ({ children }) => <h4 className="mt-2.5 mb-1 text-sm font-semibold">{children}</h4>,
          blockquote: ({ children }) => <blockquote className="border-primary/40 text-muted-foreground my-2 border-l-2 pl-3">{children}</blockquote>,
          hr: () => <hr className="my-3" />,
          img: ({ src, alt }) => <img src={typeof src === 'string' ? src : ''} alt={alt || ''} className="my-2 max-h-80 rounded-md" />,
        }}
      >
        {text}
      </ReactMarkdown>
    </div>
  )
})
```

### `src/components/chat/Files.tsx`

```tsx
import { useState } from 'react'
import { Backpack, Download, FileArchive, FileSpreadsheet, FileText, FileType2, X } from 'lucide-react'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { fmtSize } from '@/lib/format'
import type { FileDto } from '@/lib/types'
import { cn } from '@/lib/utils'

/** Иконка и цвет по типу файла: PDF / Word / Excel / архив узнаются с первого взгляда (как в Директе). */
function fileKind(f: FileDto) {
  const ext = f.name.split('.').pop()?.toLowerCase() || ''
  if (ext === 'pdf' || f.mime === 'application/pdf') return { Icon: FileText, cls: 'text-red-600 dark:text-red-400' }
  if (ext === 'docx' || ext === 'doc') return { Icon: FileType2, cls: 'text-blue-600 dark:text-blue-400' }
  if (ext === 'xlsx' || ext === 'xls' || ext === 'csv') return { Icon: FileSpreadsheet, cls: 'text-emerald-600 dark:text-emerald-400' }
  if (ext === 'zip' || ext === 'rar' || ext === '7z' || ext === 'gz') return { Icon: FileArchive, cls: 'text-amber-600 dark:text-amber-400' }
  return { Icon: FileText, cls: 'text-muted-foreground' }
}

/** Вложения сообщения: картинки превью-плиткой, остальное — строкой-файлом. */
export function Attachments({
  files,
  onToBackpack,
  align = 'start',
}: {
  files: FileDto[]
  onToBackpack?: (f: FileDto) => void
  align?: 'start' | 'end'
}) {
  const [view, setView] = useState<FileDto | null>(null)
  if (!files?.length) return null
  const images = files.filter((f) => f.isImage)
  const other = files.filter((f) => !f.isImage)
  return (
    <div className={cn('mt-1.5 flex flex-col gap-1.5', align === 'end' ? 'items-end' : 'items-start')}>
      {images.length > 0 && (
        <div className={cn('flex flex-wrap gap-1.5', align === 'end' && 'justify-end')}>
          {images.map((f) => (
            <div key={f.id} className="group/img relative">
              <button type="button" onClick={() => setView(f)} className="block overflow-hidden rounded-lg border">
                <img src={f.url} alt={f.name} loading="lazy" className="max-h-44 max-w-60 object-cover" />
              </button>
              {onToBackpack && <BackpackButton file={f} onClick={() => onToBackpack(f)} className="absolute top-1 right-1" />}
            </div>
          ))}
        </div>
      )}
      {other.map((f) => { const k = fileKind(f); return (
        <div key={f.id} className="bg-background/60 flex max-w-full items-center gap-2 rounded-lg border px-2.5 py-1.5">
          <k.Icon className={cn('size-4 shrink-0', k.cls)} />
          <a href={f.url} target="_blank" rel="noreferrer" className="min-w-0 truncate text-sm hover:underline" title={f.name}>
            {f.name}
          </a>
          <span className="text-muted-foreground shrink-0 text-xs">{fmtSize(f.size)}</span>
          <a href={`${f.url}&dl=1`} className="text-muted-foreground hover:text-foreground shrink-0" title="Скачать">
            <Download className="size-3.5" />
          </a>
          {onToBackpack && <BackpackButton file={f} onClick={() => onToBackpack(f)} />}
        </div>
      ) })}
      <ImageViewer file={view} onClose={() => setView(null)} />
    </div>
  )
}

function BackpackButton({ file, onClick, className }: { file: FileDto; onClick: () => void; className?: string }) {
  return (
    <Tooltip>
      <TooltipTrigger
        render={
          <button
            type="button"
            onClick={onClick}
            disabled={file.inBackpack}
            className={cn(
              'flex size-6 shrink-0 items-center justify-center rounded-md border backdrop-blur transition-opacity',
              file.inBackpack ? 'bg-primary text-primary-foreground border-primary' : 'bg-background/80 text-muted-foreground hover:text-foreground',
              className,
            )}
          />
        }
      >
        <Backpack className="size-3.5" />
      </TooltipTrigger>
      <TooltipContent>{file.inBackpack ? 'Уже в доп. материалах' : 'Добавить в доп. материалы'}</TooltipContent>
    </Tooltip>
  )
}

export function ImageViewer({ file, onClose }: { file: FileDto | null; onClose: () => void }) {
  return (
    <Dialog open={!!file} onOpenChange={(v) => !v && onClose()}>
      <DialogContent className="max-h-[95vh] w-auto max-w-[95vw] p-2 sm:max-w-[95vw]" showCloseButton={false}>
        <DialogTitle className="sr-only">{file?.name}</DialogTitle>
        {file && (
          <div className="relative">
            <img src={file.url} alt={file.name} className="max-h-[88vh] max-w-[92vw] rounded-md object-contain" />
            <div className="text-muted-foreground mt-1.5 flex items-center justify-between gap-3 px-1 text-xs">
              <span className="truncate">{file.name}{file.caption ? ` — ${file.caption}` : ''}</span>
              <div className="flex shrink-0 items-center gap-3">
                <a href={`${file.url}&dl=1`} className="hover:text-foreground flex items-center gap-1"><Download className="size-3.5" />Скачать</a>
                <button type="button" onClick={onClose} className="hover:text-foreground flex items-center gap-1"><X className="size-3.5" />Закрыть</button>
              </div>
            </div>
          </div>
        )}
      </DialogContent>
    </Dialog>
  )
}
```

### `src/components/chat/Composer.tsx`

```tsx
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import { ArrowUp, AtSign, FileText, Loader2, Mic, Paperclip, Quote, Square, X } from 'lucide-react'
import { useLiveDictation } from '@/lib/liveDictation'
import { joinSegments, readDraft, splitSegments, type QuoteItem, type Segment } from '@/lib/drafts'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { uploadFile, type UploadTarget } from '@/lib/upload'
import type { FileDto } from '@/lib/types'
import { cn } from '@/lib/utils'

interface Pending {
  key: string
  name: string
  preview?: string
  progress: number
  file?: FileDto
  error?: string
}

interface Props {
  target: UploadTarget
  onSend: (text: string, fileIds: string[]) => Promise<void> | void
  placeholder?: string
  disabled?: boolean
  /** Ключ черновика в localStorage: недописанное переживает переключение чатов. */
  storageKey?: string
  actions?: ReactNode
  hint?: ReactNode
  autoFocus?: boolean
  className?: string
  /** Проекты для упоминания «@Проект» — кнопка @ и подсказки при наборе. Нет списка — нет фичи. */
  mentions?: MentionTarget[]
}

export interface MentionTarget {
  id: string
  name: string
  color?: string
  /** Ветки проекта. Больше одной — в списке появляются отдельные строки «Проект › Ветка». */
  chats?: { id: string; title: string }[]
  /** Свой проект: его ветки (кроме текущей) показываются всегда, даже одна. */
  self?: boolean
}

interface MentionEntry {
  key: string
  insert: string // что встаёт в текст после «@»
  project: string
  chat?: string
  color?: string
  self?: boolean
}

const isCoarse = () => typeof window !== 'undefined' && window.matchMedia?.('(pointer: coarse)').matches

export function Composer({ target, onSend, placeholder, disabled, storageKey, actions, hint, autoFocus, className, mentions }: Props) {
  // Черновик — блоки: текст, карточка цитаты, текст… Пишем в «активный» текстовый блок (где был курсор).
  const [segs, setSegs] = useState<Segment[]>(() => splitSegments(storageKey ? readDraft(storageKey) : ''))
  const [activeId, setActiveId] = useState(() => lastTextId(segs))
  const activeRef = useRef(activeId)
  activeRef.current = segs.some((x) => x.id === activeId && x.kind === 'text') ? activeId : lastTextId(segs)
  const els = useRef(new Map<string, HTMLTextAreaElement>())
  const activeSeg = segs.find((x) => x.id === activeRef.current) as Extract<Segment, { kind: 'text' }> | undefined
  const text = activeSeg?.value ?? ''
  const setText = (v: string | ((cur: string) => string)) =>
    setSegs((ss) => ss.map((x) => (x.id === activeRef.current && x.kind === 'text' ? { ...x, value: typeof v === 'function' ? v(x.value) : v } : x)))
  const draft = joinSegments(segs)
  const [pending, setPending] = useState<Pending[]>([])
  const [sending, setSending] = useState(false)
  const [dragOver, setDragOver] = useState(false)
  const ta = useRef<HTMLTextAreaElement | null>(null)
  const fileInput = useRef<HTMLInputElement>(null)

  // Смена чата — подтягиваем его черновик.
  useEffect(() => {
    const next = splitSegments(storageKey ? readDraft(storageKey) : '')
    setSegs(next)
    setActiveId(lastTextId(next))
    setPending([])
  }, [storageKey])

  // Цитата встаёт карточкой туда, где курсор: активный текстовый блок режется на «до» и «после»,
  // курсор — в «после». textarea помнит позицию курсора и без фокуса.
  const focusSeg = (id: string, pos: number | 'end') => requestAnimationFrame(() => {
    const el = els.current.get(id)
    if (!el) return
    if (!isCoarse() || document.activeElement?.tagName === 'TEXTAREA') el.focus()
    const n = pos === 'end' ? el.value.length : pos
    el.setSelectionRange(n, n)
  })
  const segsRef = useRef(segs)
  segsRef.current = segs
  useEffect(() => {
    if (!storageKey) return
    const on = (e: Event) => {
      const d = (e as CustomEvent<{ key: string; item: QuoteItem; note?: string; handled: boolean }>).detail
      if (d.key !== storageKey) return
      d.handled = true
      const ss = segsRef.current
      const i = ss.findIndex((x) => x.id === activeRef.current)
      const cur = ss[i] as Extract<Segment, { kind: 'text' }>
      const at = els.current.get(cur.id)?.selectionEnd ?? cur.value.length
      const rest = cur.value.slice(at).replace(/^\s+/, '')
      const lead = d.note ? `${d.note} ` : ''
      const after = newText(lead + rest)
      const next = [...ss.slice(0, i), { ...cur, value: cur.value.slice(0, at).replace(/\s+$/, '') }, newQuote(d.item), after, ...ss.slice(i + 1)]
      setSegs(next)
      setActiveId(after.id)
      focusSeg(after.id, lead.length)
    }
    window.addEventListener('quote-inject', on)
    return () => window.removeEventListener('quote-inject', on)
  }, [storageKey]) // eslint-disable-line react-hooks/exhaustive-deps

  /** Убрать карточку: соседние тексты склеиваются, курсор — на стык. */
  const removeQuote = (id: string) => {
    const i = segs.findIndex((x) => x.id === id)
    const prev = segs[i - 1] as Extract<Segment, { kind: 'text' }>
    const nextSeg = segs[i + 1] as Extract<Segment, { kind: 'text' }>
    const glue = prev.value && nextSeg.value ? '\n' : ''
    setSegs([...segs.slice(0, i - 1), { ...prev, value: prev.value + glue + nextSeg.value }, ...segs.slice(i + 2)])
    setActiveId(prev.id)
    focusSeg(prev.id, prev.value.length + glue.length)
  }
  /** Соседний текстовый блок (через карточку) — для стрелок. */
  const neighbour = (id: string, dir: -1 | 1) => {
    const i = segs.findIndex((x) => x.id === id)
    return segs[i + dir * 2]?.id
  }

  const textRef = useRef(draft)
  textRef.current = draft
  useEffect(() => {
    if (!storageKey) return
    const save = () => {
      if (textRef.current) localStorage.setItem(storageKey, textRef.current)
      else localStorage.removeItem(storageKey)
    }
    const t = setTimeout(save, 300)
    return () => clearTimeout(t)
  }, [draft, storageKey])
  // Уходим из чата — дописанное за последние 300 мс тоже сохраняем.
  useEffect(() => {
    if (!storageKey) return
    return () => {
      if (textRef.current) localStorage.setItem(storageKey, textRef.current)
    }
  }, [storageKey])

  // Вставка текста снаружи (см. lib/drafts): забираем, если ключ наш.
  useEffect(() => {
    if (!storageKey) return
    const on = (e: Event) => {
      const d = (e as CustomEvent<{ key: string; text: string; handled: boolean }>).detail
      if (d.key !== storageKey) return
      d.handled = true
      const last = lastTextId(segsRef.current)
      setSegs((ss) => ss.map((x) => (x.id === last && x.kind === 'text' ? { ...x, value: x.value.trim() ? `${x.value.trimEnd()}\n\n${d.text}` : d.text } : x)))
      setActiveId(last)
      focusSeg(last, 'end')
    }
    window.addEventListener('draft-inject', on)
    return () => window.removeEventListener('draft-inject', on)
  }, [storageKey])

  useEffect(() => {
    if (autoFocus && !isCoarse()) ta.current?.focus()
  }, [autoFocus, storageKey])

  // Диктовка живая (Chrome распознаёт на лету), одна для всех; отправляет человек сам.
  // Фразы дописываются в конец активного блока, недоговорённое видно сразу и потом заменяется.
  // Человек поправил текст посреди диктовки — дальше дописываем к его варианту.
  const textNow = useRef(text)
  textNow.current = text
  const live = useRef({ base: '', written: null as string | null })
  const glue = (a: string, b: string) => (!b ? a : a.trim() ? `${a.trimEnd()} ${b}` : b)
  const liveDictation = useLiveDictation(
    (fin, interim) => {
      const l = live.current
      if (textNow.current !== l.written) l.base = textNow.current
      if (fin) l.base = glue(l.base, fin)
      const out = glue(l.base, interim)
      l.written = out
      setText(out)
    },
    (msg) => toast.error(msg),
  )
  const toggleLive = () => {
    if (!liveDictation.on) live.current.written = null
    liveDictation.toggle()
  }
  const abortLive = liveDictation.abort
  useEffect(() => abortLive, [storageKey, abortLive])

  const addFiles = useCallback(
    (list: FileList | File[]) => {
      const files = Array.from(list).slice(0, 20)
      for (const f of files) {
        const key = `${Date.now()}-${Math.random()}`
        const preview = f.type.startsWith('image/') ? URL.createObjectURL(f) : undefined
        setPending((p) => [...p, { key, name: f.name || 'файл', preview, progress: 0 }])
        uploadFile(f, target, (progress) => setPending((p) => p.map((x) => (x.key === key ? { ...x, progress } : x))))
          .then((file) => setPending((p) => p.map((x) => (x.key === key ? { ...x, file, progress: 1 } : x))))
          .catch((e) => {
            toast.error(`${f.name}: ${e.message}`)
            setPending((p) => p.map((x) => (x.key === key ? { ...x, error: e.message } : x)))
          })
      }
    },
    [target],
  )

  // ── Упоминания «@Проект»: набрал «@д» — список фильтруется; стрелки/Enter/Tab или палец — выбрать ──
  const [mq, setMq] = useState<{ query: string; start: number } | null>(null)
  const [mi, setMi] = useState(0)
  const readMention = (el: HTMLTextAreaElement) => {
    if (!mentions?.length) return
    const caret = el.selectionStart ?? el.value.length
    const m = /(^|\s)@([^\s@]*)$/.exec(el.value.slice(0, caret))
    setMq(m ? { query: m[2].toLowerCase(), start: caret - m[2].length - 1 } : null)
    setMi(0)
  }
  // Плоский список: проект, под ним его ветки (если их больше одной). «@дир/лен» — проект и ветка по частям.
  const entries: MentionEntry[] = (mentions || []).flatMap((p) => [
    { key: p.id, insert: p.name, project: p.name, color: p.color, self: p.self },
    ...((p.chats?.length || 0) > 1 || (p.self && p.chats?.length) ? p.chats! : []).map((c) => ({ key: `${p.id}/${c.id}`, insert: `${p.name}/${c.title}`, project: p.name, chat: c.title, color: p.color })),
  ])
  const found = mq
    ? entries
        .filter((e) => {
          const [pq, cq] = mq.query.split('/')
          if (cq !== undefined) return !!e.chat && e.project.toLowerCase().includes(pq) && e.chat.toLowerCase().includes(cq)
          return e.project.toLowerCase().includes(pq) || !!e.chat?.toLowerCase().includes(pq)
        })
        .slice(0, 10)
    : []
  const pickMention = (p: MentionEntry) => {
    const el = ta.current
    if (!el || !mq) return
    const caret = el.selectionStart ?? text.length
    const insert = `@${p.insert} `
    const next = text.slice(0, mq.start) + insert + text.slice(caret)
    setText(next)
    setMq(null)
    const pos = mq.start + insert.length
    requestAnimationFrame(() => { el.focus(); el.setSelectionRange(pos, pos) })
  }
  // Кнопка @: ставит «@» в место курсора — дальше тот же список, что и при наборе.
  const startMention = () => {
    const el = ta.current
    if (!el) return
    const caret = el.selectionStart ?? text.length
    const pad = caret > 0 && !/\s/.test(text[caret - 1]) ? ' ' : ''
    const next = `${text.slice(0, caret)}${pad}@${text.slice(caret)}`
    setText(next)
    const pos = caret + pad.length + 1
    requestAnimationFrame(() => { el.focus(); el.setSelectionRange(pos, pos); readMention(el) })
  }

  const uploading = pending.some((p) => !p.file && !p.error)
  const ready = pending.filter((p) => p.file)
  const canSend = !disabled && !sending && !uploading && (draft.length > 0 || ready.length > 0)

  const send = async () => {
    if (!canSend) return
    liveDictation.abort()
    setSending(true)
    try {
      await onSend(draft, ready.map((p) => p.file!.id))
      const fresh = [newText('')]
      setSegs(fresh)
      setActiveId(fresh[0].id)
      if (storageKey) localStorage.removeItem(storageKey)
      pending.forEach((p) => p.preview && URL.revokeObjectURL(p.preview))
      setPending([])
    } catch (e) {
      toast.error((e as Error).message)
    } finally {
      setSending(false)
      requestAnimationFrame(() => { if (!isCoarse()) ta.current?.focus() })
    }
  }

  return (
    <div
      className={cn('relative', className)}
      onDragOver={(e) => {
        if (e.dataTransfer.types.includes('Files')) {
          e.preventDefault()
          setDragOver(true)
        }
      }}
      onDragLeave={() => setDragOver(false)}
      onDrop={(e) => {
        if (!e.dataTransfer.files.length) return
        e.preventDefault()
        setDragOver(false)
        addFiles(e.dataTransfer.files)
      }}
    >
      {mq && mentions && (
        <div className="bg-popover text-popover-foreground absolute bottom-full left-0 z-30 mb-1.5 w-72 max-w-full overflow-hidden rounded-lg border shadow-md">
          <div className="text-muted-foreground border-b px-2.5 py-1.5 text-[11px]">
            Упомянуть проект или ветку — агент получит выдержку оттуда
          </div>
          {found.length ? (
            found.map((p, i) => (
              <button
                key={p.key}
                type="button"
                className={cn('flex w-full items-center gap-2 py-2 pr-2.5 text-left text-sm', p.chat ? 'pl-7' : 'pl-2.5', i === mi ? 'bg-accent text-accent-foreground' : 'hover:bg-muted')}
                onPointerDown={(e) => e.preventDefault()}
                onMouseEnter={() => setMi(i)}
                onClick={() => pickMention(p)}
              >
                {p.chat ? (
                  <>
                    <span className="text-muted-foreground shrink-0">›</span>
                    <span className="truncate">{p.chat}</span>
                    {!found.some((x) => !x.chat && x.project === p.project) && <span className="text-muted-foreground ml-auto shrink-0 truncate text-xs">{p.project}</span>}
                  </>
                ) : (
                  <>
                    <span className="size-2.5 shrink-0 rounded-full" style={{ background: p.color || 'var(--muted-foreground)' }} />
                    <span className="truncate">{p.project}</span>
                    {p.self ? (
                      <span className="text-primary ml-auto shrink-0 text-xs">этот проект · другие ветки</span>
                    ) : entries.some((x) => x.chat && x.project === p.project) && <span className="text-muted-foreground ml-auto shrink-0 text-xs">все ветки</span>}
                  </>
                )}
              </button>
            ))
          ) : (
            <div className="text-muted-foreground px-2.5 py-2 text-xs">Нет проекта «{mq.query}»</div>
          )}
        </div>
      )}
      <div
        className={cn(
          'bg-background focus-within:border-ring focus-within:ring-ring/30 rounded-xl border shadow-xs transition-[box-shadow,border-color] focus-within:ring-3',
          dragOver && 'border-primary ring-primary/30 ring-3',
        )}
      >
        {pending.length > 0 && (
          <div className="flex flex-wrap gap-1.5 border-b p-2">
            {pending.map((p) => (
              <div key={p.key} className={cn('bg-muted/60 relative flex items-center gap-1.5 rounded-lg border py-1 pr-6 pl-1', p.error && 'border-destructive/50')}>
                {p.preview ? (
                  <img src={p.preview} alt="" className="size-8 rounded object-cover" />
                ) : (
                  <FileText className="text-muted-foreground m-1 size-5" />
                )}
                <span className="max-w-40 truncate text-xs">{p.name}</span>
                {!p.file && !p.error && <span className="text-muted-foreground text-[10px]">{Math.round(p.progress * 100)}%</span>}
                <button
                  type="button"
                  className="text-muted-foreground hover:text-foreground absolute top-1/2 right-1 -translate-y-1/2"
                  onClick={() => {
                    if (p.preview) URL.revokeObjectURL(p.preview)
                    setPending((list) => list.filter((x) => x.key !== p.key))
                  }}
                >
                  <X className="size-3.5" />
                </button>
              </div>
            ))}
          </div>
        )}
        <div className="max-h-72 overflow-y-auto">
          {segs.map((sg, i) => {
            if (sg.kind === 'quote') {
              return (
                <div key={sg.id} className="px-2 py-1">
                  <div className="bg-muted/60 border-primary/50 relative flex gap-2 rounded-lg border-l-2 py-1.5 pr-7 pl-2">
                    <Quote className="text-primary/70 mt-0.5 size-3 shrink-0" />
                    <div className="min-w-0">
                      {sg.item.source && <div className="text-primary/80 text-[10px] font-medium">{sg.item.source}</div>}
                      <div className="text-muted-foreground line-clamp-3 text-xs break-words whitespace-pre-wrap">{sg.item.text}</div>
                    </div>
                    <button
                      type="button"
                      title="Убрать цитату"
                      className="text-muted-foreground hover:text-foreground absolute top-1.5 right-1.5"
                      onPointerDown={(e) => e.preventDefault()}
                      onClick={() => removeQuote(sg.id)}
                    >
                      <X className="size-3.5" />
                    </button>
                  </div>
                </div>
              )
            }
            const only = segs.length === 1
            const last = i === segs.length - 1
            // Пустой блок между карточками (или над первой) — тонкая щель: не пустая строка, но попасть курсором можно.
            const slim = !only && !last && !sg.value && sg.id !== activeRef.current
            return (
              <textarea
                key={sg.id}
                ref={(el) => {
                  if (el) els.current.set(sg.id, el)
                  else els.current.delete(sg.id)
                  if (el && sg.id === activeRef.current) ta.current = el
                }}
                value={sg.value}
                onFocus={(e) => { setActiveId(sg.id); activeRef.current = sg.id; ta.current = e.currentTarget }}
                onChange={(e) => {
                  const v = e.target.value
                  setSegs((ss) => ss.map((x) => (x.id === sg.id && x.kind === 'text' ? { ...x, value: v } : x)))
                  readMention(e.target)
                }}
                onSelect={(e) => readMention(e.currentTarget)}
                onBlur={() => setTimeout(() => setMq(null), 150)}
                onPaste={(e) => {
                  const files = Array.from(e.clipboardData.files || [])
                  if (files.length) {
                    e.preventDefault()
                    addFiles(files)
                  }
                }}
                onKeyDown={(e) => {
                  if (mq && found.length) {
                    if (e.key === 'ArrowDown') { e.preventDefault(); setMi((k) => (k + 1) % found.length); return }
                    if (e.key === 'ArrowUp') { e.preventDefault(); setMi((k) => (k - 1 + found.length) % found.length); return }
                    if ((e.key === 'Enter' || e.key === 'Tab') && !e.nativeEvent.isComposing) { e.preventDefault(); pickMention(found[mi] || found[0]); return }
                  }
                  if (mq && e.key === 'Escape') { e.preventDefault(); setMq(null); return }
                  const el = e.currentTarget
                  const atStart = el.selectionStart === 0 && el.selectionEnd === 0
                  const atEnd = el.selectionStart === el.value.length
                  // Backspace в начале блока после карточки — убирает карточку, как было с плашкой.
                  if (e.key === 'Backspace' && atStart && segs[i - 1]?.kind === 'quote') { e.preventDefault(); removeQuote(segs[i - 1].id); return }
                  if (e.key === 'ArrowUp' && atStart && neighbour(sg.id, -1)) { e.preventDefault(); const id = neighbour(sg.id, -1)!; setActiveId(id); focusSeg(id, 'end'); return }
                  if (e.key === 'ArrowDown' && atEnd && neighbour(sg.id, 1)) { e.preventDefault(); const id = neighbour(sg.id, 1)!; setActiveId(id); focusSeg(id, 0); return }
                  if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing && !isCoarse()) {
                    e.preventDefault()
                    send()
                  }
                }}
                rows={1}
                placeholder={only ? placeholder || 'Сообщение…' : last && !sg.value ? 'Ваш ответ…' : undefined}
                disabled={disabled}
                className={cn(
                  'field-sizing-content placeholder:text-muted-foreground block w-full resize-none bg-transparent px-3 text-sm outline-none',
                  slim ? 'h-1.5 min-h-0 py-0' : only ? 'min-h-11 pt-3 pb-1' : 'min-h-8 py-1',
                )}
              />
            )
          })}
        </div>
        <div className="flex items-center gap-1 px-2 pb-2">
          <input
            ref={fileInput}
            type="file"
            multiple
            className="hidden"
            onChange={(e) => {
              if (e.target.files?.length) addFiles(e.target.files)
              e.target.value = ''
            }}
          />
          <Button type="button" variant="ghost" size="icon-sm" onClick={() => fileInput.current?.click()} title="Прикрепить файл (или вставьте/перетащите)">
            <Paperclip className="size-4" />
          </Button>
          <Button
            type="button"
            variant="ghost"
            size={liveDictation.on ? 'sm' : 'icon-sm'}
            className={cn(liveDictation.on && 'text-destructive h-8 gap-1.5 px-2 text-xs')}
            disabled={disabled && !liveDictation.on}
            onClick={toggleLive}
            title={liveDictation.on ? 'Закончить диктовку' : 'Надиктовать'}
          >
            {liveDictation.on ? <><span className="bg-destructive size-2 animate-pulse rounded-full" /> Слушаю <Square className="size-3 fill-current" /></> : <Mic className="size-4" />}
          </Button>
          {!!mentions?.length && (
            <Button type="button" variant="ghost" size="icon-sm" title="Упомянуть проект или ветку (@)" onPointerDown={(e) => e.preventDefault()} onClick={startMention}>
              <AtSign className="size-4" />
            </Button>
          )}
          {actions}
          <div className="text-muted-foreground ml-auto hidden truncate text-[11px] sm:block">{hint}</div>
          <Button type="button" size="icon-sm" className="ml-1 shrink-0 rounded-full" disabled={!canSend} onClick={send} title="Отправить (Enter)">
            {sending || uploading ? <Loader2 className="size-4 animate-spin" /> : <ArrowUp className="size-4" />}
          </Button>
        </div>
      </div>
    </div>
  )
}

let segSeq = 0
function newText(value: string): Segment {
  return { kind: 'text', id: `t${++segSeq}`, value }
}
function newQuote(item: QuoteItem): Segment {
  return { kind: 'quote', id: `q${++segSeq}`, item }
}
function lastTextId(ss: Segment[]) {
  for (let i = ss.length - 1; i >= 0; i--) if (ss[i].kind === 'text') return ss[i].id
  return ''
}
```

### `src/components/chat/ShareMenu.tsx`
`useWorkspace()` отдаёт `projects: { id, name, color, status, lastActivityAt, chats: { id, title }[] }[]` — список проектов и веток (заглушка ниже).
```tsx
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Bot, Send } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { injectQuote, type QuoteItem } from '@/lib/drafts'
import { useWorkspace } from '@/contexts/WorkspaceContext'

interface Props {
  /** Отмеченные сообщения, в порядке ленты. */
  items: QuoteItem[]
  fromChatId: string
  fromProjectId: string
  onDone: () => void
}

/**
 * «Поделиться» — расширенная цитата: отмеченные сообщения уходят карточками цитат в поле ввода
 * другой ветки, и та сразу открывается — сопроводительное пишешь уже там, как при обычном цитировании.
 * Выбор ветки — выпадающим списком прямо у панели, без окна.
 */
export function ShareMenu({ items, fromChatId, fromProjectId, onDone }: Props) {
  const { projects } = useWorkspace()
  const navigate = useNavigate()
  const [open, setOpen] = useState(false)

  // Все ветки живых проектов: свой проект первым, дальше по свежести.
  const groups = useMemo(() => {
    const live = projects.filter((p) => p.id === fromProjectId || p.status !== 'archive')
    live.sort((a, b) => Number(b.id === fromProjectId) - Number(a.id === fromProjectId) || new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime())
    return live.map((p) => ({ ...p, chats: (p.chats || []).filter((c) => c.id !== fromChatId) })).filter((p) => p.chats.length)
  }, [projects, fromProjectId, fromChatId])

  const go = (projectId: string, chatId: string) => {
    setOpen(false)
    for (const q of items) injectQuote(`draft:agent:${chatId}`, q.text, q.source)
    onDone()
    navigate(`/p/${projectId}/c/${chatId}`)
  }

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger render={<Button size="sm" className="h-7 text-xs" disabled={!items.length} />}>
        <Send className="size-3.5" /> В ветку…
      </PopoverTrigger>
      <PopoverContent align="end" className="w-80 p-0">
        <Command>
          <CommandInput placeholder="Проект или ветка…" autoFocus />
          <CommandList className="max-h-72">
            <CommandEmpty>Ничего не нашлось</CommandEmpty>
            {groups.map((p) => (
              <CommandGroup key={p.id} heading={p.name}>
                {p.chats.map((c) => (
                  <CommandItem key={c.id} value={`${p.name} ${c.title} #${c.id}`} onSelect={() => go(p.id, c.id)}>
                    <span className="size-2.5 shrink-0 rounded-full" style={{ background: p.color }} />
                    <Bot className="text-muted-foreground size-3.5 shrink-0" />
                    <span className="truncate">{c.title}</span>
                    {p.id === fromProjectId && <span className="text-muted-foreground ml-auto text-[10px]">этот проект</span>}
                  </CommandItem>
                ))}
              </CommandGroup>
            ))}
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  )
}
```

### `src/lib/liveDictation.ts`
ЗАГЛУШКА. В МойЧате: в Chrome — Web Speech API, в остальных браузерах звук идёт на сервер. Интерфейс тот же — Composer работает без изменений.
```ts
export const useLiveDictation = (_onTranscript: (finalText: string, interimText: string) => void, _onError: (msg: string) => void) =>
  ({ on: false, start() {}, stop() {}, abort() {}, toggle() {} })
```

### `src/lib/clock.ts`
ЗАГЛУШКА: в МойЧате — поправка на часы сервера.
```ts
export const serverNow = () => Date.now()
export const syncClock = (_t?: number) => {}
```

### `src/lib/voice.ts`
ЗАГЛУШКА озвучки (кнопка «озвучить» под ответом).
```ts
export const useSpeaking = () => null as string | null
export const speak = async (_text: string, _key: string) => {}
export const stopSpeaking = () => {}
```

### `shared/turnThinking.ts`
ЗАГЛУШКА подписи уровня размышления в строке под ответом.
```ts
export const thinkingLabel = (_t: unknown) => ''
export const thinkingTitle = (_t: unknown) => ''
```

### `src/contexts/WorkspaceContext.tsx`
ЗАГЛУШКА списка проектов и веток для ShareMenu.
```tsx
export const useWorkspace = () => ({ projects: [
  { id: '1', name: 'CEO', color: '#e0a040', status: 'active', lastActivityAt: new Date().toISOString(), chats: [{ id: '11', title: 'Основной' }] },
  { id: '2', name: 'БАУ', color: '#d6457a', status: 'active', lastActivityAt: new Date().toISOString(), chats: [{ id: '21', title: 'Основной' }, { id: '22', title: 'Воронка' }] },
] })
```

### `src/components/chat/feed.tsx`
Целиком из рабочего файла: `UserText` (цитаты внутри реплики), `ShareButton`, `QUOTE_ACTIONS`, `QuoteButton`, `MessageItem` (галочки «Поделиться»), `MessageBody`, `AssistantMessage` (строка «шаги · время · модель», действия при наведении), `StepsToggle`/`StepsList`, `LiveBlock` («Работает»). Убраны только карточки плана этапов и служебные реплики сервера — к чату они не относятся.
```tsx
import { useState } from 'react'
import { AlertTriangle, Check, ChevronDown, ChevronRight, Copy, CornerUpRight, HelpCircle, Loader2, LifeBuoy, NotebookPen, Play, Quote, Share2, Square, Volume2, Wrench } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Markdown } from './Markdown'
import { Attachments } from './Files'
import { quoteSource } from '@/lib/drafts'
import { fmtDuration, fmtTime } from '@/lib/format'
import { serverNow } from '@/lib/clock'
import { speak, stopSpeaking, useSpeaking } from '@/lib/voice'
import { thinkingLabel, thinkingTitle } from '../../../shared/turnThinking'
import type { AgentMessage, FileDto, LiveRun, ToolStep } from '@/lib/types'
import { cn } from '@/lib/utils'


const SHOWN_STEP = 40

const MODEL_LABEL: Record<string, string> = { fable: 'Fable', opus: 'Opus', sonnet: 'Sonnet', haiku: 'Haiku' }

// Модели Codex в подписи ответа: gpt-6-astra → Astra, gpt-5.6-luna → Luna.
const CODEX_LABEL: Record<string, string> = { 'codex-unknown': 'Codex (модель не подтверждена)', 'gpt-6.1-sol': 'Sol 6.1', 'gpt-6-astra': 'Astra', 'gpt-5.6-sol': 'Sol 5.6', 'gpt-5.6-terra': 'Terra', 'gpt-5.6-luna': 'Luna', 'gpt-5.5': 'GPT-5.5' }

export function modelName(raw?: string | null): string {
  if (!raw) return ''
  if (CODEX_LABEL[raw]) return CODEX_LABEL[raw]
  const m = /fable|opus|sonnet|haiku/i.exec(raw)?.[0]?.toLowerCase()
  return m ? MODEL_LABEL[m] : raw
}

function UserText({ text }: { text: string }) {
  const parts: { quote: boolean; lines: string[] }[] = []
  for (const line of text.split('\n')) {
    const q = /^> ?/.test(line)
    const last = parts[parts.length - 1]
    const body = q ? line.replace(/^> ?/, '') : line
    if (last && last.quote === q) last.lines.push(body)
    else parts.push({ quote: q, lines: [body] })
  }
  return (
    <div className="flex flex-col gap-1.5 text-sm break-words">
      {parts.map((p, i) => {
        const t = p.lines.join('\n').replace(/^\n+|\n+$/g, '')
        if (!t) return null
        if (p.quote) {
          // Первая строка «[Opus · 23.09 08:46]» — подпись источника.
          const head = /^\[(.+)\]$/.exec(p.lines[0].trim())
          const body = (head ? p.lines.slice(1) : p.lines).join('\n').replace(/^\n+|\n+$/g, '')
          return (
            <div key={i} className="border-primary/50 text-muted-foreground border-l-2 pl-2 text-xs">
              {head && <div className="text-primary/80 text-[10px] font-medium">{head[1]}</div>}
              <div className="whitespace-pre-wrap">{body}</div>
            </div>
          )
        }
        return (
          <div key={i} className="whitespace-pre-wrap">{t}</div>
        )
      })}
    </div>
  )
}

function ShareButton({ onClick }: { onClick: () => void }) {
  return (
    <button type="button" className="hover:text-foreground flex items-center gap-1" onClick={onClick} title="Отметить несколько сообщений и отправить в другую ветку с письмом">
      <Share2 className="size-3" /> поделиться
    </button>
  )
}

/** Кнопки над выделением: цитата + готовая реплика под ней (курсор после неё, отправляет пользователь). */
export const QUOTE_ACTIONS = [
  { label: 'Цитировать', note: undefined, icon: Quote, tone: 'text-primary' },
  { label: 'Делай', note: 'делай', icon: Play, tone: 'text-emerald-400' },
  { label: 'Не понял', note: 'не понял', icon: HelpCircle, tone: 'text-amber-400' },
] as const

function QuoteButton({ onClick }: { onClick: () => void }) {
  return (
    <button type="button" className="hover:text-foreground flex items-center gap-1" onClick={onClick}>
      <Quote className="size-3" /> цитировать
    </button>
  )
}


/** Кто прислал служебную реплику (meta.source): совпадает с NOTE_SOURCES в server.js + внутренние источники. */
const NOTE_SOURCE_LABEL: Record<string, string> = { audit: 'сервер аудита', b24: 'Битрикс24', max: 'Max', secretary: 'секретарь', tasks: 'вкладка «Задачи»', plan: 'план' }
type Pick = { on: boolean; toggle: () => void }
export function MessageItem({
  m, onToClient, onToBackpack, onCancel, onQuote, onShare, onPlanChanged, pick,
}: {
  m: AgentMessage
  onToClient?: (t: string) => void
  onToBackpack?: (f: FileDto) => void
  onCancel: () => void
  onQuote: (t: string, source?: string) => void
  onShare: () => void
  onPlanChanged?: () => void
  /** Режим «Поделиться»: галочка слева, клик по сообщению — отметить. */
  pick?: Pick
}) {
  // Сырые итоги фоновых задач Claude — только для передачи ветки другому движку, в ленте не показываем.
  if (m.role === 'system' && m.meta?.kind === 'task_notification') return null
  if (m.role === 'system') {
    return <div className="text-muted-foreground self-center rounded-full border px-3 py-1 text-center text-[11px]">{m.content}</div>
  }
  if (pick) {
    return (
      <div
        role="checkbox"
        aria-checked={pick.on}
        tabIndex={0}
        onClick={pick.toggle}
        onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); pick.toggle() } }}
        className={cn('-mx-2 flex cursor-pointer items-start gap-2 rounded-xl px-2 py-1 transition-colors', pick.on ? 'bg-primary/10 ring-primary/40 ring-1' : 'hover:bg-muted/50')}
      >
        <Checkbox checked={pick.on} className="mt-2 pointer-events-none" tabIndex={-1} />
        <div className="pointer-events-none min-w-0 flex-1 select-none">
          <MessageBody m={m} onToClient={onToClient} onToBackpack={onToBackpack} onCancel={onCancel} onQuote={onQuote} onShare={onShare} />
        </div>
      </div>
    )
  }
  return <MessageBody m={m} onToClient={onToClient} onToBackpack={onToBackpack} onCancel={onCancel} onQuote={onQuote} onShare={onShare} />
}

function MessageBody({
  m, onToClient, onToBackpack, onCancel, onQuote, onShare,
}: {
  m: AgentMessage
  onToClient?: (t: string) => void
  onToBackpack?: (f: FileDto) => void
  onCancel: () => void
  onQuote: (t: string, source?: string) => void
  onShare: () => void
}) {
  if (m.role === 'user') {
    const cancelled = !!m.meta?.cancelled
    return (
      <div className="group/msg flex flex-col items-end gap-1" data-quote-src={quoteSource('Вы', m.createdAt)}>
        <div className={cn('bg-primary/10 max-w-[85%] rounded-2xl rounded-br-md px-3.5 py-2', cancelled && 'opacity-50 line-through')}>
          {m.content && <UserText text={m.content} />}
          <Attachments files={m.files} onToBackpack={onToBackpack} align="end" />
        </div>
        <div className="text-muted-foreground flex items-center gap-2 text-[10px]">
          {m.content && !cancelled && (
            <span className="flex items-center gap-2 opacity-0 transition-opacity group-hover/msg:opacity-100 max-sm:opacity-100">
              <QuoteButton onClick={() => onQuote(m.content, quoteSource('Вы', m.createdAt))} />
              <ShareButton onClick={onShare} />
            </span>
          )}
          {m.meta?.queued && (
            <>
              <span className="text-amber-600 dark:text-amber-400">в очереди</span>
              <button type="button" className="hover:text-foreground underline" onClick={onCancel}>снять</button>
            </>
          )}
          {cancelled && <span>снято из очереди</span>}
          {m.meta?.injected === 'pending' && <span className="text-amber-600 dark:text-amber-400">дослано · агент прочтёт на ближайшем шаге</span>}
          {m.meta?.injected === 'taken' && <span>дослано · агент прочитал</span>}
          {fmtTime(m.createdAt)}
        </div>
      </div>
    )
  }
  return <AssistantMessage m={m} onToClient={onToClient} onQuote={onQuote} onShare={onShare} onToBackpack={onToBackpack} />
}

function AssistantMessage({ m, onToClient, onQuote, onShare, onToBackpack }: { m: AgentMessage; onToClient?: (t: string) => void; onQuote: (t: string, source?: string) => void; onShare: () => void; onToBackpack?: (f: FileDto) => void }) {
  const src = quoteSource(modelName(m.meta?.model) || 'Opus', m.createdAt)
  const [copied, setCopied] = useState(false)
  const speaking = useSpeaking() === `msg-${m.id}`
  const [open, setOpen] = useState(!!m.meta?.summary ? false : true)
  const tools = m.meta?.tools || []
  const status = m.meta?.status
  const isSummary = !!m.meta?.summary
  const body = (
    <>
      {m.content ? <Markdown text={m.content} /> : null}
      {/* Файлы, которые агент отдал за ход ($MOYCHAT_OUTBOX), — под текстом ответа. */}
      <Attachments files={m.files} onToBackpack={onToBackpack} />
      {m.meta?.error && (
        <div className="border-destructive/40 bg-destructive/5 text-destructive mt-2 flex gap-2 rounded-lg border p-2.5 text-xs">
          <AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
          <div className="min-w-0 break-words whitespace-pre-wrap">{m.meta.error}</div>
        </div>
      )}
    </>
  )
  return (
    <div className="group/msg flex flex-col gap-1" data-quote-src={src}>
      {isSummary ? (
        <div className="bg-muted/40 rounded-xl border">
          <button type="button" className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium" onClick={() => setOpen((v) => !v)}>
            <NotebookPen className="size-3.5" /> Сводка сессии
            {open ? <ChevronDown className="ml-auto size-3.5" /> : <ChevronRight className="ml-auto size-3.5" />}
          </button>
          {open && <div className="border-t px-3 py-2">{body}</div>}
        </div>
      ) : (
        <div className="min-w-0">{body}</div>
      )}
      <div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px]">
        {tools.length > 0 && <StepsToggle tools={tools} subagents={m.meta?.subagents || 0} />}
        {m.meta?.auto && <span className="text-primary">сам, по итогам фоновой задачи</span>}
        {status === 'stopped' && <span className="text-amber-600">остановлено</span>}
        {status === 'interrupted' && <span className="text-amber-600">прервано рестартом</span>}
        {m.meta?.durationMs ? <span>{fmtDuration(m.meta.durationMs)}</span> : null}
        {m.meta?.model ? <span title={m.meta.models?.length ? `Модели главного агента за ход: ${m.meta.models.map(modelName).join(', ')}` : undefined}>{m.meta.models && m.meta.models.length > 1 ? m.meta.models.map(modelName).join(' + ') : modelName(m.meta.model)}{thinkingLabel(m.meta.thinking) && <span title={thinkingTitle(m.meta.thinking)}> · {thinkingLabel(m.meta.thinking)}</span>}</span> : null}
        {m.meta?.usageComplete === false && <span title="Шлюз мог дописать расход после обрыва соединения">учёт частичный</span>}
        <span>{fmtTime(m.createdAt)}</span>
        {m.content && (
          <span className="flex flex-wrap items-center gap-x-2 gap-y-1 opacity-0 transition-opacity group-hover/msg:opacity-100 max-sm:opacity-100">
            <button
              type="button"
              className="hover:text-foreground flex items-center gap-1"
              onClick={() => navigator.clipboard?.writeText(m.content).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500) })}
            >
              {copied ? <Check className="size-3" /> : <Copy className="size-3" />} копировать
            </button>
            <QuoteButton onClick={() => onQuote(m.content, src)} />
            <ShareButton onClick={onShare} />
            <button
              type="button"
              className={cn('hover:text-foreground flex items-center gap-1', speaking && 'text-primary')}
              onClick={() => (speaking ? stopSpeaking() : speak(m.content, `msg-${m.id}`).catch((e) => toast.error(e.message)))}
            >
              {speaking ? <Square className="size-3 fill-current" /> : <Volume2 className="size-3" />} {speaking ? 'стоп' : 'озвучить'}
            </button>
            {onToClient && (
              <button type="button" className="hover:text-foreground flex items-center gap-1" onClick={() => onToClient(m.content)}>
                <CornerUpRight className="size-3" /> клиенту
              </button>
            )}
          </span>
        )}
      </div>
    </div>
  )
}

function StepsToggle({ tools, subagents }: { tools: ToolStep[]; subagents: number }) {
  const [open, setOpen] = useState(false)
  return (
    <span className="relative">
      <button type="button" className="hover:text-foreground flex items-center gap-1" onClick={() => setOpen((v) => !v)}>
        <Wrench className="size-3" /> {tools.length} шаг{plural(tools.length, '', 'а', 'ов')}
        {subagents ? ` · ${subagents} субагент${plural(subagents, '', 'а', 'ов')}` : ''}
        {open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
      </button>
      {open && <StepsList tools={tools} className="mt-1.5" />}
    </span>
  )
}

function StepsList({ tools, className }: { tools: ToolStep[]; className?: string }) {
  return (
    <div className={cn('bg-muted/40 flex max-h-72 min-w-72 flex-col gap-0.5 overflow-y-auto rounded-lg border p-2 font-mono text-[11px]', className)}>
      {tools.map((t, i) => (
        <div key={i} className={cn('flex min-w-0 gap-2', t.error && 'text-destructive')}>
          <span className="text-foreground/70 shrink-0">{t.name}</span>
          <span className="truncate" title={t.summary}>{t.summary}</span>
        </div>
      ))}
    </div>
  )
}

const GATEWAY_PHASE_LABEL: Record<NonNullable<LiveRun['gatewayStatus']>['phase'], string> = {
  unknown: 'Состояние шага пока не подтверждено',
  waiting: 'Ожидаю ответ модели',
  thinking: 'Получен сигнал размышления',
  compacting: 'Идёт свёртка контекста',
  answering: 'Получаю ответ',
  finishing: 'Шлюз завершает шаг',
}

export function LiveBlock({ live, onStop, onRescue }: { live: LiveRun; onStop: () => void; onRescue: () => void }) {
  const [showSteps, setShowSteps] = useState(false)
  const now = serverNow()
  const elapsed = now - live.startedAt
  const last = live.tools[live.tools.length - 1]
  const long = elapsed > 3 * 60 * 1000
  const gateway = live.stopping ? undefined : live.gatewayStatus
  // Связь со шлюзом не подтверждает прогресс модели: возраст события шага считаем отдельно.
  const eventAge = gateway?.lastEventAt != null ? now - gateway.lastEventAt : null
  const stale = gateway && (eventAge ?? now - gateway.since) > 90_000
  const gatewayFresh = gateway?.connected && gateway.gatewayAt != null && now - gateway.gatewayAt < 75_000
  return (
    <div className="flex flex-col gap-2">
      <div className="bg-muted/40 flex flex-wrap items-center gap-x-3 gap-y-1 rounded-xl border px-3 py-2 text-xs">
        <Loader2 className="text-primary size-3.5 animate-spin" />
        <span className="font-medium">{live.stopping ? 'Останавливается' : live.purpose === 'summary' ? 'Пишет сводку' : live.purpose === 'close' ? 'Пишет итог ветки — потом она удалится' : live.purpose === 'auto' ? 'Продолжает сам (фоновая задача готова)' : 'Работает'}</span>
        <span className="text-muted-foreground">{fmtDuration(elapsed)}</span>
        {last && (
          <button type="button" className="text-muted-foreground hover:text-foreground flex min-w-0 items-center gap-1" onClick={() => setShowSteps((v) => !v)}>
            <Wrench className="size-3 shrink-0" />
            <span className="shrink-0">шаг {live.toolCount}:</span>
            <span className="max-w-72 truncate font-mono">{last.name} — {last.summary}</span>
            {showSteps ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
          </button>
        )}
        <div className="ml-auto flex items-center gap-1">
          {long && (
            <Button variant="ghost" size="xs" className="gap-1" onClick={onRescue}>
              <LifeBuoy className="size-3" /> Помощник
            </Button>
          )}
          <Button variant="ghost" size="xs" className="text-destructive gap-1" onClick={onStop} disabled={live.stopping}>
            <Square className="size-3 fill-current" /> Стоп
          </Button>
        </div>
        {gateway && (
          <div className="text-muted-foreground flex w-full flex-wrap items-center gap-x-3 gap-y-1">
            <span>{stale ? `Нет новых событий шага · последнее известное состояние: ${GATEWAY_PHASE_LABEL[gateway.phase]}` : GATEWAY_PHASE_LABEL[gateway.phase]}</span>
            <span>{fmtDuration(now - gateway.since)}</span>
            <span>{eventAge != null ? `событие шага ${fmtDuration(eventAge)} назад` : 'событий шага ещё нет'}</span>
            <span>{gatewayFresh ? 'шлюз на связи' : 'нет свежего сигнала шлюза'}</span>
          </div>
        )}
      </div>
      {showSteps && live.tools.length > 0 && <StepsList tools={live.tools} />}
      {live.text && (
        <div className="min-w-0" data-quote-src={quoteSource(modelName(live.model) || (live.engine === 'codex' ? 'Codex' : 'Claude'), serverNow())}>
          <Markdown text={live.text} />
          <span className="bg-primary/70 ml-0.5 inline-block h-3.5 w-1.5 animate-pulse rounded-sm align-middle" />
        </div>
      )}
    </div>
  )
}


export function plural(n: number, one: string, few: string, many: string) {
  const m10 = n % 10
  const m100 = n % 100
  if (m10 === 1 && m100 !== 11) return one
  if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return few
  return many
}
```

### `src/components/chat/AgentChatView.tsx`
Состояние, живые события, выделение текста, «Поделиться» и поле ввода с кнопками. Обработчики событий, выделение и панель «Поделиться» — из рабочего файла; загрузка чата и кнопки поля ввода упрощены (в МойЧате вокруг них ещё шапка ветки, озвучка, свёртка сессии, план этапов).
```tsx
import { useCallback, useEffect, useRef, useState } from 'react'
import { Brain, MessageCircleQuestion, Share2, Square, Zap } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { api } from '@/lib/api'
import { useServerEvent } from '@/lib/events'
import { injectQuote, quoteSource, type QuoteItem } from '@/lib/drafts'
import type { AgentMessage, LiveRun } from '@/lib/types'
import { cn } from '@/lib/utils'
import { Composer } from './Composer'
import { ShareMenu } from './ShareMenu'
import { LiveBlock, MessageItem, QUOTE_ACTIONS, modelName, plural } from './feed'

/** Иконка GitHub для кнопки «Зафиксировать работу в git». */
export function GithubCat({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" className={className}>
      <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
    </svg>
  )
}

export function AgentChatView({ chatId, projectId }: { chatId: string; projectId: string }) {
  const [messages, setMessages] = useState<AgentMessage[]>([])
  const [live, setLive] = useState<LiveRun | null>(null)
  const liveRef = useRef<LiveRun | null>(null)
  const setLiveNow = (l: LiveRun | null) => { liveRef.current = l; setLive(l) }
  const scroller = useRef<HTMLDivElement>(null)
  const load = useCallback(async () => {
    const r = await api.get<{ messages: AgentMessage[]; live: LiveRun | null }>(`/api/agent-chats/${chatId}`)
    if (r.data) { setMessages(r.data.messages); setLiveNow(r.data.live) }
  }, [chatId])
  useEffect(() => { load() }, [load])
  // Таймер «Работает · 12 с» — перерисовка раз в секунду, пока идёт ход.
  const [, tick] = useState(0)
  useEffect(() => { if (!live) return; const t = setInterval(() => tick((x) => x + 1), 1000); return () => clearInterval(t) }, [live])

  // ── Живые события ─────────────────────────────────────────────
  const mine = (d: { chatId?: string }) => d?.chatId === chatId
  useServerEvent('agent:message', (d) => {
    if (!mine(d)) return
    setMessages((ms) => (ms.some((m) => m.id === d.message.id) ? ms : [...ms, d.message]))
  })
  useServerEvent('agent:start', (d) => {
    if (!mine(d)) return
    setLiveNow(d.live)
    // Всё, что стояло в очереди, агент забрал этим запуском.
    setMessages((ms) => ms.map((m) => (m.meta?.queued ? { ...m, meta: { ...m.meta, queued: false } } : m)))
    load()
  })
  useServerEvent('agent:injected', (d) => {
    if (!mine(d)) return
    const ids = new Set<string>(d.ids)
    setMessages((ms) => ms.map((m) => (ids.has(m.id) ? { ...m, meta: { ...m.meta, injected: d.state } } : m)))
  })
  useServerEvent('agent:delta', (d) => {
    if (!mine(d)) return
    const cur = liveRef.current
    if (!cur || cur.runId !== d.runId) { load(); return }
    const base = d.total - d.delta.length
    if (cur.text.length === base) setLiveNow({ ...cur, text: cur.text + d.delta })
    else if (cur.text.length < base) load()
  })
  useServerEvent('agent:tool', (d) => {
    if (!mine(d)) return
    const cur = liveRef.current
    if (!cur || cur.runId !== d.runId) return
    setLiveNow({ ...cur, tools: [...cur.tools, d.tool].slice(-40), toolCount: d.toolCount })
  })
  useServerEvent('agent:status', (d: { chatId: string; runId: string; status: LiveRun['gatewayStatus'] | null }) => {
    if (!mine(d)) return
    const cur = liveRef.current
    if (!cur || cur.runId !== d.runId) return
    setLiveNow({ ...cur, gatewayStatus: d.status ?? undefined })
  })
  useServerEvent('agent:stopping', (d) => {
    if (!mine(d)) return
    if (liveRef.current) setLiveNow({ ...liveRef.current, stopping: true })
  })
  useServerEvent('agent:done', (d) => {
    if (!mine(d)) return
    setLiveNow(null)
    if (!d.message && d.status === 'error') {
      toast.error('Агент завершился с ошибкой — подробности в чате')
      load()
    }
    if (d.message) setMessages((ms) => (ms.some((m) => m.id === d.message.id) ? ms : [...ms, d.message]))
  })

  const send = async (text: string, fileIds: string[]) => {
    const r = await api.post<{ message: AgentMessage; queued: boolean; live: LiveRun | null }>(`/api/agent-chats/${chatId}/messages`, { text, fileIds })
    if (r.data) {
      setMessages((ms) => (ms.some((m) => m.id === r.data!.message.id) ? ms : [...ms, r.data!.message]))
      if (r.data.live && !liveRef.current) setLiveNow(r.data.live)
      if (r.data.queued) toast.info('Агент занят — сообщение уйдёт следующим')
    }
  }
  const stop = async () => { try { await api.post(`/api/agent-chats/${chatId}/stop`) } catch (e) { toast.error((e as Error).message) } }
  // Снять реплику из очереди, пока агент её не взял.
  const cancelQueued = async (m: AgentMessage) => { try { await api.delete(`/api/agent-chats/${chatId}/queue/${m.id}`); load() } catch (e) { toast.error((e as Error).message) } }
  const quick = async (text: string) => { try { await send(text, []) } catch (e) { toast.error((e as Error).message) } }
  // «Умный режим»: следующая реплика уходит с пометкой «думай на максимуме» (в МойЧате — служебный префикс).
  const [smart, setSmart] = useState(false)
  const sendFromComposer = async (text: string, fileIds: string[]) => { await send(smart ? `[думай на максимуме]\n${text}` : text, fileIds); setSmart(false) }

  // ── Цитаты: сообщение целиком (кнопка под ним) или выделенный кусок (всплывающая кнопка) ──
  const draftKey = `draft:agent:${chatId}`
  // ── «Поделиться»: расширенная цитата — отметить несколько сообщений и отправить их в другую ветку с письмом ──
  const [picked, setPicked] = useState<Set<string> | null>(null) // null — обычный режим
  const startShare = (id: string) => setPicked(new Set([id]))
  const togglePick = (id: string) => setPicked((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n })
  const shareItems: QuoteItem[] = picked
    ? messages.filter((m) => picked.has(m.id) && m.content).map((m) => ({ text: m.content, source: m.role === 'user' ? quoteSource('Вы', m.createdAt) : quoteSource(modelName(m.meta?.model) || 'Opus', m.createdAt) }))
    : []
  const item = (m: AgentMessage) => (
    <MessageItem
      key={m.id}
      m={m}
      onCancel={() => cancelQueued(m)}
      onQuote={quote}
      onShare={() => startShare(m.id)}
            pick={picked && m.content ? { on: picked.has(m.id), toggle: () => togglePick(m.id) } : undefined}
    />
  )
  const quote = (text: string, source?: string, note?: string) => {
    injectQuote(draftKey, text, source, note)
    window.getSelection()?.removeAllRanges()
    setSel(null)
  }
  const [sel, setSel] = useState<{ text: string; source?: string; x: number; y: number } | null>(null)
  useEffect(() => {
    let hideTimer: ReturnType<typeof setTimeout> | null = null
    const on = () => {
      const s = window.getSelection()
      const text = s && !s.isCollapsed ? s.toString().trim() : ''
      const el = scroller.current
      if (text && el && s!.rangeCount && el.contains(s!.anchorNode) && el.contains(s!.focusNode)) {
        if (hideTimer) clearTimeout(hideTimer)
        const r = s!.getRangeAt(0).getBoundingClientRect()
        // Источник — по сообщениям, где началось и кончилось выделение (может захватить два).
        const srcOf = (n: Node) => (n instanceof Element ? n : n.parentElement)?.closest('[data-quote-src]')?.getAttribute('data-quote-src') || ''
        const range = s!.getRangeAt(0)
        const a = srcOf(range.startContainer)
        const z = srcOf(range.endContainer)
        const source = a && z && a !== z ? `${a} → ${z}` : a || z || undefined
        setSel({ text, source, x: r.left + r.width / 2, y: r.top })
      } else {
        // Тап по кнопке на телефоне сначала снимает выделение — даём клику долететь.
        if (hideTimer) clearTimeout(hideTimer)
        hideTimer = setTimeout(() => setSel(null), 250)
      }
    }
    document.addEventListener('selectionchange', on)
    const el = scroller.current
    el?.addEventListener('scroll', on, { passive: true }) // кнопка едет вместе с выделением
    return () => {
      document.removeEventListener('selectionchange', on)
      el?.removeEventListener('scroll', on)
      if (hideTimer) clearTimeout(hideTimer)
    }
  }, [])
  const coarse = typeof window !== 'undefined' && window.matchMedia?.('(pointer: coarse)').matches

  return (
    <div className="flex h-full min-h-0 flex-col">
      <div ref={scroller} className="min-h-0 flex-1 overflow-y-auto px-3 py-4 sm:px-5">
        <div className="mx-auto flex max-w-4xl flex-col gap-5">
          {messages.map(item)}
          {live && <LiveBlock live={live} onStop={stop} onRescue={() => {}} />}
        </div>
      </div>


      {/* Выделил кусок в ленте — «Цитировать». Мышь: над выделением. Телефон: над полем ввода,
          там системное меню Chrome не перекрывает. preventDefault на нажатии — чтобы выделение не слетело до клика. */}
      {sel && !coarse && sel.y > 0 && (
        <div
          className="bg-popover text-popover-foreground fixed z-50 flex -translate-x-1/2 -translate-y-full items-center gap-0.5 rounded-lg border p-0.5 text-xs font-medium shadow-md"
          style={{ left: sel.x, top: sel.y - 6 }}
          onMouseDown={(e) => e.preventDefault()}
        >
          {QUOTE_ACTIONS.map((a) => (
            <button key={a.label} type="button" className="hover:bg-muted flex items-center gap-1.5 rounded-md px-2 py-1" onClick={() => quote(sel.text, sel.source, a.note)}>
              <a.icon className={cn('size-3.5', a.tone)} /> {a.label}
            </button>
          ))}
        </div>
      )}

      {/* Ввод */}
      <div className="shrink-0 px-3 pt-1 pb-3 sm:px-5">
        <div className="mx-auto max-w-4xl">
          {picked && (
            <div className="bg-popover mb-1.5 flex items-center gap-2 rounded-lg border px-3 py-2 text-xs shadow-sm">
              <Share2 className="text-primary size-3.5 shrink-0" />
              <span className="min-w-0 flex-1 truncate">
                <span className="font-medium">Поделиться:</span> отмечено {shareItems.length} {plural(shareItems.length, 'сообщение', 'сообщения', 'сообщений')} — отметь ещё и выбери ветку: уйдут цитатами в её поле ввода
              </span>
              <ShareMenu items={shareItems} fromChatId={chatId} fromProjectId={projectId} onDone={() => setPicked(null)} />
              <Button size="sm" variant="ghost" className="h-7 text-xs" onClick={() => setPicked(null)}>Отмена</Button>
            </div>
          )}
          {sel && coarse && (
            <div className="bg-popover text-popover-foreground mb-1.5 flex w-full items-center gap-1 rounded-lg border p-1 text-xs shadow-sm" onPointerDown={(e) => e.preventDefault()}>
              <span className="text-muted-foreground min-w-0 flex-1 truncate px-1.5">«{sel.text}»</span>
              {QUOTE_ACTIONS.map((a) => (
                <button key={a.label} type="button" className="hover:bg-muted flex shrink-0 items-center gap-1 rounded-md px-2 py-1.5 font-medium" onClick={() => quote(sel.text, sel.source, a.note)}>
                  <a.icon className={cn('size-3.5', a.tone)} /> {a.label}
                </button>
              ))}
            </div>
          )}
          <Composer
            target={{ projectId }}
            storageKey={`draft:agent:${chatId}`}
            onSend={sendFromComposer}
            autoFocus
            placeholder={live ? 'Агент работает — сообщение встанет в очередь…' : 'Задача агенту… (файлы — вставьте или перетащите)'}
            hint={<>Enter — отправить · Shift+Enter — перенос</>}
            actions={
              <>
                <DropdownMenu>
                  <DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon-sm" title="Короткие ответы" />}>
                    <Zap className="size-4" />
                  </DropdownMenuTrigger>
                  <DropdownMenuContent align="start" side="top" className="min-w-64">
                    <div className="text-muted-foreground px-2 py-1.5 text-xs font-medium">Короткие ответы — агент только читает и отвечает</div>
                    {[20, 50, 100].map((n) => (
                      <DropdownMenuItem key={n} onClick={() => quick(`Отвечай коротко, до ${n} слов`)}>до {n} слов</DropdownMenuItem>
                    ))}
                    <DropdownMenuItem onClick={() => quick('Отвечай как обычно')}><span className="text-destructive">Отвечать как обычно</span></DropdownMenuItem>
                  </DropdownMenuContent>
                </DropdownMenu>
                <Button type="button" variant="ghost" size="icon-sm" title="Умный режим — следующий ответ с максимумом размышления" aria-pressed={smart}
                  className={cn(smart && 'bg-primary/15 text-primary hover:bg-primary/20 hover:text-primary')}
                  onPointerDown={(e) => e.preventDefault()} onClick={() => setSmart((v) => !v)}>
                  <Brain className="size-4" />
                </Button>
                <Button type="button" variant="ghost" size="icon-sm" title="Зафиксировать работу в git" onClick={() => quick('Зафиксируй работу в git')}>
                  <GithubCat className="size-4" />
                </Button>
                <Button type="button" variant="ghost" size="icon-sm" title="Спросить «как дела?»" onClick={() => quick('как дела?')}>
                  <MessageCircleQuestion className="size-4" />
                </Button>
                {live && (
                  <Button type="button" variant="ghost" size="sm" className="text-destructive h-8 gap-1 px-2 text-xs" onClick={stop} disabled={live.stopping}>
                    <Square className="size-3.5 fill-current" /> {live.stopping ? 'Останавливаю…' : 'Стоп'}
                  </Button>
                )}
              </>
            }
          />
        </div>
      </div>
    </div>
  )
}
```

### `src/lib/events.tsx`
Упрощённая версия рабочего модуля (в МойЧате ещё синхронизация часов, отзыв сессии и индикатор связи).
```tsx
// src/lib/events.tsx — подписка на поток событий сервера (SSE через fetch: EventSource не умеет заголовок авторизации).
import { createContext, useContext, useEffect, useRef, type ReactNode } from 'react'
import { TOKEN_KEY } from '@/lib/api'

type Handler = (data: any) => void
const Ctx = createContext<Map<string, Set<Handler>> | null>(null)

export function EventsProvider({ children }: { children: ReactNode }) {
  const subs = useRef(new Map<string, Set<Handler>>()).current
  useEffect(() => {
    const ctrl = new AbortController()
    let stop = false
    ;(async () => {
      while (!stop) {
        try {
          const res = await fetch('/api/events', { headers: { Authorization: `Bearer ${localStorage.getItem(TOKEN_KEY)}` }, signal: ctrl.signal })
          const reader = res.body!.getReader(); const dec = new TextDecoder(); let buf = ''
          for (;;) {
            const { value, done } = await reader.read(); if (done) break
            buf += dec.decode(value, { stream: true })
            let i
            while ((i = buf.indexOf('\n\n')) >= 0) {
              const chunk = buf.slice(0, i); buf = buf.slice(i + 2)
              const ev = /^event: (.+)$/m.exec(chunk)?.[1]; const data = /^data: (.+)$/m.exec(chunk)?.[1]
              if (ev && data) subs.get(ev)?.forEach((h) => h(JSON.parse(data)))
            }
          }
        } catch { /* обрыв — переподключаемся */ }
        if (!stop) await new Promise((r) => setTimeout(r, 2000))
      }
    })()
    return () => { stop = true; ctrl.abort() }
  }, [subs])
  return <Ctx.Provider value={subs}>{children}</Ctx.Provider>
}

export function useServerEvent(event: string, handler: Handler) {
  const subs = useContext(Ctx)!
  const ref = useRef(handler); ref.current = handler
  useEffect(() => {
    const h: Handler = (d) => ref.current(d)
    if (!subs.has(event)) subs.set(event, new Set())
    subs.get(event)!.add(h)
    return () => { subs.get(event)!.delete(h) }
  }, [subs, event])
}
```

### `src/components/ui/button.tsx`
shadcn, как в проекте (если `npx shadcn add` доступен — можно поставить им).
```tsx
import { Button as ButtonPrimitive } from '@base-ui/react/button'
import { cva, type VariantProps } from 'class-variance-authority'

import { cn } from '@/lib/utils'

const buttonVariants = cva(
  "group/button focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:ring-3 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground hover:bg-primary/80',
        outline:
          'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 shadow-xs',
        secondary:
          'bg-secondary text-secondary-foreground aria-expanded:bg-secondary aria-expanded:text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]',
        ghost:
          'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
        destructive:
          'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
        link: 'text-primary underline-offset-4 hover:underline'
      },
      size: {
        default:
          'h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
        xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
        sm: 'h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5',
        lg: 'h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
        icon: 'size-9',
        'icon-xs':
          "size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
        'icon-sm': 'size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md',
        'icon-lg': 'size-10'
      }
    },
    defaultVariants: {
      variant: 'default',
      size: 'default'
    }
  }
)

function Button({
  className,
  variant = 'default',
  size = 'default',
  ...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
  return <ButtonPrimitive data-slot='button' className={cn(buttonVariants({ variant, size, className }))} {...props} />
}

export { Button, buttonVariants }
```

### `src/components/ui/popover.tsx`
shadcn, как в проекте (если `npx shadcn add` доступен — можно поставить им).
```tsx
'use client'

import * as React from 'react'

import { Popover as PopoverPrimitive } from '@base-ui/react/popover'

import { cn } from '@/lib/utils'

function Popover({ ...props }: PopoverPrimitive.Root.Props) {
  return <PopoverPrimitive.Root data-slot='popover' {...props} />
}

function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
  return <PopoverPrimitive.Trigger data-slot='popover-trigger' {...props} />
}

function PopoverContent({
  className,
  align = 'center',
  alignOffset = 0,
  side = 'bottom',
  sideOffset = 4,
  ...props
}: PopoverPrimitive.Popup.Props &
  Pick<PopoverPrimitive.Positioner.Props, 'align' | 'alignOffset' | 'side' | 'sideOffset'>) {
  return (
    <PopoverPrimitive.Portal>
      <PopoverPrimitive.Positioner
        align={align}
        alignOffset={alignOffset}
        side={side}
        sideOffset={sideOffset}
        className='isolate z-50'
      >
        <PopoverPrimitive.Popup
          data-slot='popover-content'
          className={cn(
            'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 z-50 flex w-72 origin-(--transform-origin) flex-col gap-4 rounded-md p-4 text-sm shadow-md ring-1 outline-hidden duration-100',
            className
          )}
          {...props}
        />
      </PopoverPrimitive.Positioner>
    </PopoverPrimitive.Portal>
  )
}

function PopoverHeader({ className, ...props }: React.ComponentProps<'div'>) {
  return <div data-slot='popover-header' className={cn('flex flex-col gap-1 text-sm', className)} {...props} />
}

function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
  return <PopoverPrimitive.Title data-slot='popover-title' className={cn('font-medium', className)} {...props} />
}

function PopoverDescription({ className, ...props }: PopoverPrimitive.Description.Props) {
  return (
    <PopoverPrimitive.Description
      data-slot='popover-description'
      className={cn('text-muted-foreground', className)}
      {...props}
    />
  )
}

export { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger }
```

### `src/components/ui/command.tsx`
shadcn, как в проекте (если `npx shadcn add` доступен — можно поставить им).
```tsx
'use client'

import * as React from 'react'

import { Command as CommandPrimitive } from 'cmdk'

import { CheckIcon, SearchIcon } from 'lucide-react'

import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { InputGroup, InputGroupAddon } from '@/components/ui/input-group'
import { cn } from '@/lib/utils'

function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
  return (
    <CommandPrimitive
      data-slot='command'
      className={cn(
        'bg-popover text-popover-foreground flex size-full flex-col overflow-hidden rounded-xl! p-1',
        className
      )}
      {...props}
    />
  )
}

function CommandDialog({
  title = 'Command Palette',
  description = 'Search for a command to run...',
  children,
  className,
  showCloseButton = false,
  ...props
}: Omit<React.ComponentProps<typeof Dialog>, 'children'> & {
  title?: string
  description?: string
  className?: string
  showCloseButton?: boolean
  children: React.ReactNode
}) {
  return (
    <Dialog {...props}>
      <DialogHeader className='sr-only'>
        <DialogTitle>{title}</DialogTitle>
        <DialogDescription>{description}</DialogDescription>
      </DialogHeader>
      <DialogContent
        className={cn('top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0', className)}
        showCloseButton={showCloseButton}
      >
        {children}
      </DialogContent>
    </Dialog>
  )
}

function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
  return (
    <div data-slot='command-input-wrapper' className='p-1 pb-0'>
      <InputGroup className='border-input/30 bg-input/30 h-8! rounded-lg! shadow-none! *:data-[slot=input-group-addon]:pl-2!'>
        <CommandPrimitive.Input
          data-slot='command-input'
          className={cn('w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50', className)}
          {...props}
        />
        <InputGroupAddon>
          <SearchIcon className='size-4 shrink-0 opacity-50' />
        </InputGroupAddon>
      </InputGroup>
    </div>
  )
}

function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
  return (
    <CommandPrimitive.List
      data-slot='command-list'
      className={cn('no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none', className)}
      {...props}
    />
  )
}

function CommandEmpty({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
  return (
    <CommandPrimitive.Empty
      data-slot='command-empty'
      className={cn('py-6 text-center text-sm', className)}
      {...props}
    />
  )
}

function CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) {
  return (
    <CommandPrimitive.Group
      data-slot='command-group'
      className={cn(
        'text-foreground **:[[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium',
        className
      )}
      {...props}
    />
  )
}

function CommandSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) {
  return (
    <CommandPrimitive.Separator
      data-slot='command-separator'
      className={cn('bg-border -mx-1 h-px w-auto', className)}
      {...props}
    />
  )
}

function CommandItem({ className, children, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
  return (
    <CommandPrimitive.Item
      data-slot='command-item'
      className={cn(
        "group/command-item data-selected:bg-muted data-selected:text-foreground data-selected:**:[svg]:text-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    >
      {children}
      <CheckIcon className='ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100' />
    </CommandPrimitive.Item>
  )
}

function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>) {
  return (
    <span
      data-slot='command-shortcut'
      className={cn(
        'text-muted-foreground group-data-selected/command-item:text-foreground ml-auto text-xs tracking-widest',
        className
      )}
      {...props}
    />
  )
}

export {
  Command,
  CommandDialog,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  CommandSeparator,
  CommandShortcut
}
```

### `src/components/ui/dialog.tsx`
shadcn, как в проекте (если `npx shadcn add` доступен — можно поставить им).
```tsx
'use client'

import * as React from 'react'

import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'

import { XIcon } from 'lucide-react'

import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'

function Dialog({ ...props }: DialogPrimitive.Root.Props) {
  return <DialogPrimitive.Root data-slot='dialog' {...props} />
}

function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
  return <DialogPrimitive.Trigger data-slot='dialog-trigger' {...props} />
}

function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
  return <DialogPrimitive.Portal data-slot='dialog-portal' {...props} />
}

function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
  return <DialogPrimitive.Close data-slot='dialog-close' {...props} />
}

function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
  return (
    <DialogPrimitive.Backdrop
      data-slot='dialog-overlay'
      className={cn(
        'data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs',
        className
      )}
      {...props}
    />
  )
}

function DialogContent({
  className,
  children,
  showCloseButton = true,
  ...props
}: DialogPrimitive.Popup.Props & {
  showCloseButton?: boolean
}) {
  return (
    <DialogPortal>
      <DialogOverlay />
      <DialogPrimitive.Popup
        data-slot='dialog-content'
        className={cn(
          'bg-popover text-popover-foreground ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-xl p-6 text-sm ring-1 duration-100 outline-none sm:max-w-md',
          className
        )}
        {...props}
      >
        {children}
        {showCloseButton && (
          <DialogPrimitive.Close
            data-slot='dialog-close'
            render={<Button variant='ghost' className='absolute top-4 right-4' size='icon-sm' />}
          >
            <XIcon />
            <span className='sr-only'>Close</span>
          </DialogPrimitive.Close>
        )}
      </DialogPrimitive.Popup>
    </DialogPortal>
  )
}

function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
  return <div data-slot='dialog-header' className={cn('flex flex-col gap-2', className)} {...props} />
}

function DialogFooter({
  className,
  showCloseButton = false,
  children,
  ...props
}: React.ComponentProps<'div'> & {
  showCloseButton?: boolean
}) {
  return (
    <div
      data-slot='dialog-footer'
      className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
      {...props}
    >
      {children}
      {showCloseButton && <DialogPrimitive.Close render={<Button variant='outline' />}>Close</DialogPrimitive.Close>}
    </div>
  )
}

function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
  return (
    <DialogPrimitive.Title
      data-slot='dialog-title'
      className={cn('font-heading leading-none font-medium', className)}
      {...props}
    />
  )
}

function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
  return (
    <DialogPrimitive.Description
      data-slot='dialog-description'
      className={cn(
        'text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3',
        className
      )}
      {...props}
    />
  )
}

export {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogOverlay,
  DialogPortal,
  DialogTitle,
  DialogTrigger
}
```

### `src/components/ui/tooltip.tsx`
shadcn, как в проекте (если `npx shadcn add` доступен — можно поставить им).
```tsx
'use client'

import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip'

import { cn } from '@/lib/utils'

function TooltipProvider({ delay = 0, ...props }: TooltipPrimitive.Provider.Props) {
  return <TooltipPrimitive.Provider data-slot='tooltip-provider' delay={delay} {...props} />
}

function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
  return <TooltipPrimitive.Root data-slot='tooltip' {...props} />
}

function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
  return <TooltipPrimitive.Trigger data-slot='tooltip-trigger' {...props} />
}

function TooltipContent({
  className,
  side = 'top',
  sideOffset = 4,
  align = 'center',
  alignOffset = 0,
  children,
  ...props
}: TooltipPrimitive.Popup.Props &
  Pick<TooltipPrimitive.Positioner.Props, 'align' | 'alignOffset' | 'side' | 'sideOffset'>) {
  return (
    <TooltipPrimitive.Portal>
      <TooltipPrimitive.Positioner
        align={align}
        alignOffset={alignOffset}
        side={side}
        sideOffset={sideOffset}
        className='isolate z-50'
      >
        <TooltipPrimitive.Popup
          data-slot='tooltip-content'
          className={cn(
            'bg-foreground text-background data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md px-3 py-1.5 text-xs has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm',
            className
          )}
          {...props}
        >
          {children}
          <TooltipPrimitive.Arrow className='bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5' />
        </TooltipPrimitive.Popup>
      </TooltipPrimitive.Positioner>
    </TooltipPrimitive.Portal>
  )
}

export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
```

### `src/components/ui/checkbox.tsx`
shadcn, как в проекте (если `npx shadcn add` доступен — можно поставить им).
```tsx
'use client'

import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox'

import { CheckIcon } from 'lucide-react'

import { cn } from '@/lib/utils'

function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
  return (
    <CheckboxPrimitive.Root
      data-slot='checkbox'
      className={cn(
        'peer border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border shadow-xs transition-shadow outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3',
        className
      )}
      {...props}
    >
      <CheckboxPrimitive.Indicator
        data-slot='checkbox-indicator'
        className='grid place-content-center text-current transition-none [&>svg]:size-3.5'
      >
        <CheckIcon />
      </CheckboxPrimitive.Indicator>
    </CheckboxPrimitive.Root>
  )
}

export { Checkbox }
```

### `src/components/ui/dropdown-menu.tsx`
shadcn, как в проекте (если `npx shadcn add` доступен — можно поставить им).
```tsx
'use client'

import * as React from 'react'

import { Menu as MenuPrimitive } from '@base-ui/react/menu'

import { ChevronRightIcon, CheckIcon } from 'lucide-react'

import { cn } from '@/lib/utils'

function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
  return <MenuPrimitive.Root data-slot='dropdown-menu' {...props} />
}

function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
  return <MenuPrimitive.Portal data-slot='dropdown-menu-portal' {...props} />
}

function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
  return <MenuPrimitive.Trigger data-slot='dropdown-menu-trigger' {...props} />
}

function DropdownMenuContent({
  align = 'start',
  alignOffset = 0,
  side = 'bottom',
  sideOffset = 4,
  className,
  ...props
}: MenuPrimitive.Popup.Props & Pick<MenuPrimitive.Positioner.Props, 'align' | 'alignOffset' | 'side' | 'sideOffset'>) {
  return (
    <MenuPrimitive.Portal>
      <MenuPrimitive.Positioner
        className='isolate z-50 outline-none'
        align={align}
        alignOffset={alignOffset}
        side={side}
        sideOffset={sideOffset}
      >
        <MenuPrimitive.Popup
          data-slot='dropdown-menu-content'
          className={cn(
            'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md p-1 shadow-md ring-1 duration-100 outline-none data-closed:overflow-hidden',
            className
          )}
          {...props}
        />
      </MenuPrimitive.Positioner>
    </MenuPrimitive.Portal>
  )
}

function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
  return <MenuPrimitive.Group data-slot='dropdown-menu-group' {...props} />
}

function DropdownMenuLabel({
  className,
  inset,
  ...props
}: MenuPrimitive.GroupLabel.Props & {
  inset?: boolean
}) {
  return (
    <MenuPrimitive.GroupLabel
      data-slot='dropdown-menu-label'
      data-inset={inset}
      className={cn('text-muted-foreground px-2 py-1.5 text-xs font-medium data-inset:pl-8', className)}
      {...props}
    />
  )
}

function DropdownMenuItem({
  className,
  inset,
  variant = 'default',
  ...props
}: MenuPrimitive.Item.Props & {
  inset?: boolean
  variant?: 'default' | 'destructive'
}) {
  return (
    <MenuPrimitive.Item
      data-slot='dropdown-menu-item'
      data-inset={inset}
      data-variant={variant}
      className={cn(
        "group/dropdown-menu-item focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:*:[svg]:text-destructive relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    />
  )
}

function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
  return <MenuPrimitive.SubmenuRoot data-slot='dropdown-menu-sub' {...props} />
}

function DropdownMenuSubTrigger({
  className,
  inset,
  children,
  ...props
}: MenuPrimitive.SubmenuTrigger.Props & {
  inset?: boolean
}) {
  return (
    <MenuPrimitive.SubmenuTrigger
      data-slot='dropdown-menu-sub-trigger'
      data-inset={inset}
      className={cn(
        "focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    >
      {children}
      <ChevronRightIcon className='ml-auto' />
    </MenuPrimitive.SubmenuTrigger>
  )
}

function DropdownMenuSubContent({
  align = 'start',
  alignOffset = -3,
  side = 'right',
  sideOffset = 0,
  className,
  ...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
  return (
    <DropdownMenuContent
      data-slot='dropdown-menu-sub-content'
      className={cn(
        'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 w-auto min-w-[96px] rounded-md p-1 shadow-lg ring-1 duration-100',
        className
      )}
      align={align}
      alignOffset={alignOffset}
      side={side}
      sideOffset={sideOffset}
      {...props}
    />
  )
}

function DropdownMenuCheckboxItem({
  className,
  children,
  checked,
  inset,
  ...props
}: MenuPrimitive.CheckboxItem.Props & {
  inset?: boolean
}) {
  return (
    <MenuPrimitive.CheckboxItem
      data-slot='dropdown-menu-checkbox-item'
      data-inset={inset}
      className={cn(
        "focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      checked={checked}
      {...props}
    >
      <span
        className='pointer-events-none absolute right-2 flex items-center justify-center'
        data-slot='dropdown-menu-checkbox-item-indicator'
      >
        <MenuPrimitive.CheckboxItemIndicator>
          <CheckIcon />
        </MenuPrimitive.CheckboxItemIndicator>
      </span>
      {children}
    </MenuPrimitive.CheckboxItem>
  )
}

function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
  return <MenuPrimitive.RadioGroup data-slot='dropdown-menu-radio-group' {...props} />
}

function DropdownMenuRadioItem({
  className,
  children,
  inset,
  ...props
}: MenuPrimitive.RadioItem.Props & {
  inset?: boolean
}) {
  return (
    <MenuPrimitive.RadioItem
      data-slot='dropdown-menu-radio-item'
      data-inset={inset}
      className={cn(
        "focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    >
      <span
        className='pointer-events-none absolute right-2 flex items-center justify-center'
        data-slot='dropdown-menu-radio-item-indicator'
      >
        <MenuPrimitive.RadioItemIndicator>
          <CheckIcon />
        </MenuPrimitive.RadioItemIndicator>
      </span>
      {children}
    </MenuPrimitive.RadioItem>
  )
}

function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
  return (
    <MenuPrimitive.Separator
      data-slot='dropdown-menu-separator'
      className={cn('bg-border -mx-1 my-1 h-px', className)}
      {...props}
    />
  )
}

function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
  return (
    <span
      data-slot='dropdown-menu-shortcut'
      className={cn(
        'text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest',
        className
      )}
      {...props}
    />
  )
}

export {
  DropdownMenu,
  DropdownMenuPortal,
  DropdownMenuTrigger,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuLabel,
  DropdownMenuItem,
  DropdownMenuCheckboxItem,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSeparator,
  DropdownMenuShortcut,
  DropdownMenuSub,
  DropdownMenuSubTrigger,
  DropdownMenuSubContent
}
```

### `src/components/ui/input-group.tsx`
shadcn, как в проекте (если `npx shadcn add` доступен — можно поставить им).
```tsx
'use client'

import * as React from 'react'

import { cva, type VariantProps } from 'class-variance-authority'

import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'

function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
  return (
    <div
      data-slot='input-group'
      role='group'
      className={cn(
        'group/input-group border-input has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:bg-input/30 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 relative flex h-9 w-full min-w-0 items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-3 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5',
        className
      )}
      {...props}
    />
  )
}

const inputGroupAddonVariants = cva(
  "text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
  {
    variants: {
      align: {
        'inline-start': 'order-first pl-2 has-[>button]:-ml-1 has-[>kbd]:ml-[-0.15rem]',
        'inline-end': 'order-last pr-2 has-[>button]:-mr-1 has-[>kbd]:mr-[-0.15rem]',
        'block-start':
          'order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2',
        'block-end': 'order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2'
      }
    },
    defaultVariants: {
      align: 'inline-start'
    }
  }
)

function InputGroupAddon({
  className,
  align = 'inline-start',
  ...props
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
  return (
    <div
      role='group'
      data-slot='input-group-addon'
      data-align={align}
      className={cn(inputGroupAddonVariants({ align }), className)}
      onClick={e => {
        if ((e.target as HTMLElement).closest('button')) {
          return
        }

        e.currentTarget.parentElement?.querySelector('input')?.focus()
      }}
      {...props}
    />
  )
}

const inputGroupButtonVariants = cva('flex items-center gap-2 text-sm shadow-none', {
  variants: {
    size: {
      xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
      sm: '',
      'icon-xs': 'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
      'icon-sm': 'size-8 p-0 has-[>svg]:p-0'
    }
  },
  defaultVariants: {
    size: 'xs'
  }
})

function InputGroupButton({
  className,
  type = 'button',
  variant = 'ghost',
  size = 'xs',
  ...props
}: Omit<React.ComponentProps<typeof Button>, 'size' | 'type'> &
  VariantProps<typeof inputGroupButtonVariants> & {
    type?: 'button' | 'submit' | 'reset'
  }) {
  return (
    <Button
      type={type}
      data-size={size}
      variant={variant}
      className={cn(inputGroupButtonVariants({ size }), className)}
      {...props}
    />
  )
}

function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
  return (
    <span
      className={cn(
        "text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    />
  )
}

function InputGroupInput({ className, ...props }: React.ComponentProps<'input'>) {
  return (
    <Input
      data-slot='input-group-control'
      className={cn(
        'flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent',
        className
      )}
      {...props}
    />
  )
}

function InputGroupTextarea({ className, ...props }: React.ComponentProps<'textarea'>) {
  return (
    <Textarea
      data-slot='input-group-control'
      className={cn(
        'flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent',
        className
      )}
      {...props}
    />
  )
}

export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupInput, InputGroupTextarea }
```

### `src/components/ui/input.tsx`
shadcn, как в проекте (если `npx shadcn add` доступен — можно поставить им).
```tsx
import * as React from 'react'

import { Input as InputPrimitive } from '@base-ui/react/input'

import { cn } from '@/lib/utils'

function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
  return (
    <InputPrimitive
      type={type}
      data-slot='input'
      className={cn(
        'border-input file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 h-9 w-full min-w-0 rounded-md border bg-transparent px-2.5 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-3 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3 md:text-sm',
        className
      )}
      {...props}
    />
  )
}

export { Input }
```

### `src/components/ui/textarea.tsx`
shadcn, как в проекте (если `npx shadcn add` доступен — можно поставить им).
```tsx
import * as React from 'react'

import { cn } from '@/lib/utils'

function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
  return (
    <textarea
      data-slot='textarea'
      className={cn(
        'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-2.5 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3 md:text-sm',
        className
      )}
      {...props}
    />
  )
}

export { Textarea }
```

## 7. Чек-лист приёмки

- [ ] Выделил мышью кусок ответа — над выделением «Цитировать · Делай · Не понял»; на телефоне — полоса над полем ввода.
- [ ] «Цитировать» с курсором посреди текста: текст разрезан, карточка между частями, курсор под ней.
- [ ] «Делай»: под карточкой текст «делай », курсор после него. Backspace в начале строки под карточкой убирает карточку, тексты склеиваются.
- [ ] Отправка: агент получает `> [Opus · 12.10 12:44]` + строки `> …` + текст; в ленте цитата — серый блок с полоской и подписью внутри пузыря.
- [ ] Во время хода: блок «Работает · N с · шаг K: Name — summary · Стоп», текст печатается с мигающим курсором; реплика в это время уходит в ход («дослано»).
- [ ] Под ответом: «🔧 N шагов ›», длительность, модель, время; при наведении — копировать, цитировать, поделиться, озвучить.
- [ ] «поделиться» → галочки слева, отметил несколько → «В ветку…» → поиск ветки → ветка открылась, в поле ввода цитаты по порядку ленты.
- [ ] Вставка картинки из буфера / перетаскивание файла — миниатюра полосой сверху поля ввода; файл > 16 МБ уходит кусками (`upload.ts`).
- [ ] Короткие ответы / умный режим / git / «как дела?» — отправляют свои реплики; «Стоп» останавливает ход.
