From 5cbb7ebf8ba1c25f3e9f6b394cce14b8366ba598 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 23:22:07 +0000 Subject: [PATCH 1/4] feat: add /doc slash command and remove space requirement for /file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /file now triggers the picker immediately without requiring a space; characters typed right after (/filefoo) become the search query - /doc command added for documents: searches by name, and by ID if the query is numeric (ID result shown first, merged with name results) - Document chips use the ⟦doc:ID:TITLE⟧ token format and expand to document title + description when the message is sent - FilePickerDropdown extended with a type prop to render doc items (title + #ID, folder/document icon) alongside existing file items - i18n: added docInput keys in ru and en Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01BA6oSYD68zVgMGt7L2QQiU --- frontend/src/api/documentsApi.js | 13 +++ .../components/chatPanel/FileChipInput.jsx | 104 ++++++++++++++---- .../chatPanel/FilePickerDropdown.jsx | 85 +++++++++----- .../src/components/chatPanel/fileChips.js | 36 +++++- frontend/src/i18n/locales/en/chat.json | 6 + frontend/src/i18n/locales/ru/chat.json | 6 + 6 files changed, 198 insertions(+), 52 deletions(-) create mode 100644 frontend/src/api/documentsApi.js diff --git a/frontend/src/api/documentsApi.js b/frontend/src/api/documentsApi.js new file mode 100644 index 0000000..a221c96 --- /dev/null +++ b/frontend/src/api/documentsApi.js @@ -0,0 +1,13 @@ +import { request } from './client'; + +const documentsApi = { + searchByName: (name, limit = 10, signal) => { + const params = new URLSearchParams({ name, limit: String(limit) }); + return request(`/api/documents/search-by-name?${params}`, signal ? { signal } : undefined); + }, + getById: (id, signal) => { + return request(`/api/documents/${id}`, signal ? { signal } : undefined); + }, +}; + +export default documentsApi; diff --git a/frontend/src/components/chatPanel/FileChipInput.jsx b/frontend/src/components/chatPanel/FileChipInput.jsx index 6ea00d6..17c73ba 100644 --- a/frontend/src/components/chatPanel/FileChipInput.jsx +++ b/frontend/src/components/chatPanel/FileChipInput.jsx @@ -2,14 +2,39 @@ import React, { useRef, useEffect, useCallback, useState, useImperativeHandle, f import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import gitApi from '../../api/gitApi'; -import { makeToken, makeRefToken, parseToken, baseName, fetchContent, TOKEN_RE } from './fileChips'; +import documentsApi from '../../api/documentsApi'; +import { makeToken, makeRefToken, makeDocToken, parseToken, parseDocToken, baseName, fetchContent, TOKEN_RE } from './fileChips'; import FilePickerDropdown from './FilePickerDropdown'; import useEscape from '../common/useEscape'; import { IconX } from '../../icons'; const DEBOUNCE_MS = 200; -const TRIGGER = '/file '; -const TRIGGER_RE = /(?:^|\s)\/file (\S*)$/; +const FILE_TRIGGER = '/file'; +const FILE_TRIGGER_RE = /(?:^|\s)\/file\s*(\S*)$/; +const DOC_TRIGGER = '/doc'; +const DOC_TRIGGER_RE = /(?:^|\s)\/doc\s*(\S*)$/; + +async function searchDocsAsync(q, signal) { + const isNumeric = q.length > 0 && /^\d+$/.test(q); + if (!isNumeric) { + return documentsApi.searchByName(q, 10, signal); + } + const [nameRes, idRes] = await Promise.all([ + documentsApi.searchByName(q, 10, signal).catch((e) => { + if (e.name === 'AbortError') throw e; + return []; + }), + documentsApi.getById(Number(q), signal).catch((e) => { + if (e.name === 'AbortError') throw e; + return null; + }), + ]); + const results = Array.isArray(nameRes) ? [...nameRes] : []; + if (idRes && !results.find((r) => r.id === idRes.id)) { + results.unshift(idRes); + } + return results; +} // ── Сериализация DOM ⇄ плоская строка с токенами ─────────────────────────────── @@ -31,6 +56,32 @@ function serialize(root) { /** Построить DOM-элемент чипа из строки-токена. */ function makeChipEl(token) { + const docParsed = parseDocToken(token); + if (docParsed) { + const chip = document.createElement('span'); + chip.className = 'file-chip file-chip--doc'; + chip.contentEditable = 'false'; + chip.dataset.token = token; + chip.title = `${docParsed.title} (#${docParsed.id})`; + + const icon = document.createElement('span'); + icon.className = 'file-chip__icon'; + icon.textContent = '📋'; + + const label = document.createElement('span'); + label.className = 'file-chip__label'; + label.textContent = docParsed.title; + + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'file-chip__remove'; + remove.textContent = '×'; + remove.tabIndex = -1; + + chip.append(icon, label, remove); + return chip; + } + const parsed = parseToken(token); const path = parsed?.path ?? token; const range = parsed?.from != null ? `:${parsed.from}-${parsed.to}` : ''; @@ -108,7 +159,7 @@ const FileChipInput = forwardRef(function FileChipInput( const internalRef = useRef(value); const triggerRef = useRef(null); - const [picker, setPicker] = useState({ open: false, query: '', results: [], loading: false, anchor: null, idx: 0 }); + const [picker, setPicker] = useState({ open: false, query: '', results: [], loading: false, anchor: null, idx: 0, type: 'file' }); const [preview, setPreview] = useState(null); // { path, from, to, refOnly, rect, chipEl, data, loading, error } const debounceTimer = useRef(null); @@ -144,13 +195,15 @@ const FileChipInput = forwardRef(function FileChipInput( setPicker((p) => (p.open ? { ...p, open: false, results: [], query: '' } : p)); }, []); - const runSearch = useCallback((q) => { + const runSearch = useCallback((q, type) => { abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; setPicker((p) => ({ ...p, loading: true })); - gitApi - .searchFiles(q, 10, controller.signal) + const search = type === 'doc' + ? searchDocsAsync(q, controller.signal) + : gitApi.searchFiles(q, 10, controller.signal); + search .then((data) => setPicker((p) => ({ ...p, loading: false, results: Array.isArray(data) ? data : [], idx: 0 }))) .catch((err) => { if (err.name !== 'AbortError') setPicker((p) => ({ ...p, loading: false, results: [] })); @@ -165,20 +218,28 @@ const FileChipInput = forwardRef(function FileChipInput( if (node.nodeType !== Node.TEXT_NODE) return dismissPicker(); const before = node.nodeValue.slice(0, range.startOffset); - const m = before.match(TRIGGER_RE); + + let m = before.match(FILE_TRIGGER_RE); + let type = 'file'; + if (!m) { + m = before.match(DOC_TRIGGER_RE); + type = 'doc'; + } if (!m) return dismissPicker(); const query = m[1]; - const start = range.startOffset - TRIGGER.length - query.length; - triggerRef.current = { node, start, query }; + const commandStr = type === 'file' ? FILE_TRIGGER : DOC_TRIGGER; + const start = before.lastIndexOf(commandStr); + + triggerRef.current = { node, start, cursorOffset: range.startOffset, query, type }; const rect = range.getBoundingClientRect(); const anchor = rect && (rect.top || rect.left) ? { top: rect.top, left: rect.left } : null; - setPicker((p) => ({ ...p, open: true, query, anchor, idx: 0 })); + setPicker((p) => ({ ...p, open: true, query, anchor, idx: 0, type })); clearTimeout(debounceTimer.current); if (query.length >= 1) { - debounceTimer.current = setTimeout(() => runSearch(query), DEBOUNCE_MS); + debounceTimer.current = setTimeout(() => runSearch(query, type), DEBOUNCE_MS); } else { setPicker((p) => ({ ...p, results: [], loading: false })); } @@ -206,17 +267,18 @@ const FileChipInput = forwardRef(function FileChipInput( setPreview((pv) => (pv ? null : pv)); }, [emitChange, detectTrigger]); - const insertFile = useCallback( - (fileNode) => { + const insertItem = useCallback( + (item) => { const trig = triggerRef.current; const root = editorRef.current; if (!trig || !root) return; - const { node, start, query } = trig; + const { node, start, cursorOffset, type } = trig; const before = node.nodeValue.slice(0, start); - const after = node.nodeValue.slice(start + TRIGGER.length + query.length); + const after = node.nodeValue.slice(cursorOffset); - const chip = makeChipEl(makeToken(fileNode.path)); + const token = type === 'doc' ? makeDocToken(item.id, item.title) : makeToken(item.path); + const chip = makeChipEl(token); const tail = document.createTextNode(' ' + after); node.nodeValue = before; node.after(chip, tail); @@ -290,7 +352,7 @@ const FileChipInput = forwardRef(function FileChipInput( } if (e.key === 'Enter' && picker.results.length > 0) { e.preventDefault(); - insertFile(picker.results[picker.idx]); + insertItem(picker.results[picker.idx]); return; } if (e.key === 'Escape') { @@ -338,7 +400,7 @@ const FileChipInput = forwardRef(function FileChipInput( emitChange(); } }, - [picker.open, picker.results, picker.idx, insertFile, dismissPicker, disabled, onSend, emitChange], + [picker.open, picker.results, picker.idx, insertItem, dismissPicker, disabled, onSend, emitChange], ); // Переключение чипа между режимами «содержимое» и «только путь». @@ -377,6 +439,7 @@ const FileChipInput = forwardRef(function FileChipInput( const chip = e.target.closest?.('.file-chip'); if (chip) { e.preventDefault(); + if (parseDocToken(chip.dataset.token)) return; const parsed = parseToken(chip.dataset.token); if (!parsed) return; const rect = chip.getBoundingClientRect(); @@ -419,8 +482,9 @@ const FileChipInput = forwardRef(function FileChipInput( query={picker.query} anchorRect={picker.anchor} selectedIdx={picker.idx} - onSelect={insertFile} + onSelect={insertItem} onDismiss={dismissPicker} + type={picker.type} /> )} diff --git a/frontend/src/components/chatPanel/FilePickerDropdown.jsx b/frontend/src/components/chatPanel/FilePickerDropdown.jsx index 3564e69..bb7ba4d 100644 --- a/frontend/src/components/chatPanel/FilePickerDropdown.jsx +++ b/frontend/src/components/chatPanel/FilePickerDropdown.jsx @@ -3,19 +3,20 @@ import { useTranslation } from 'react-i18next'; import { IconFileText } from '../../icons'; /** - * Плавающий список результатов поиска файлов репозитория для триггера `/file`. + * Плавающий список результатов поиска для триггеров `/file` (файлы репозитория) и `/doc` (документы). * Открывается НАД кареткой (композер прижат к низу окна). * * Props: - * results — GitFileNode[] { path, name, size } + * results — GitFileNode[] { path, name, size } | DocumentNode[] { id, title, type } * loading — boolean * query — string (для пустого состояния) * anchorRect — DOMRect-like { top, left } каретки * selectedIdx — number + * type — 'file' | 'doc' * onSelect(node) — клик/Enter * onDismiss() — закрыть */ -const FilePickerDropdown = ({ results, loading, query, anchorRect, selectedIdx, onSelect, onDismiss }) => { +const FilePickerDropdown = ({ results, loading, query, anchorRect, selectedIdx, onSelect, onDismiss, type = 'file' }) => { const { t } = useTranslation('chat'); const listRef = useRef(null); @@ -40,49 +41,73 @@ const FilePickerDropdown = ({ results, loading, query, anchorRect, selectedIdx, zIndex: 9100, }; + const hintKey = query + ? type === 'doc' ? t('docInput.hintQuery', { query }) : t('fileInput.hintQuery', { query }) + : type === 'doc' ? t('docInput.hintStart') : t('fileInput.hintStart'); + + const searchingLabel = type === 'doc' ? t('docInput.searching') : t('fileInput.searching'); + const emptyLabel = type === 'doc' ? t('docInput.empty') : t('fileInput.empty'); + return (
- - {query ? t('fileInput.hintQuery', { query }) : t('fileInput.hintStart')} - + {hintKey}
{loading && (
- {t('fileInput.searching')} + {searchingLabel}
)} {!loading && results.length === 0 && query.length >= 1 && ( -
{t('fileInput.empty')}
+
{emptyLabel}
)}
- {results.map((node, i) => { - const dir = node.path.includes('/') ? node.path.slice(0, node.path.lastIndexOf('/')) : ''; - return ( -
{ - e.preventDefault(); - onSelect(node); - }} - > - - - - - {node.name} - - {dir || node.path} + {type === 'doc' + ? results.map((node, i) => ( +
{ + e.preventDefault(); + onSelect(node); + }} + > + + {node.type === 'folder' ? '📁' : '📋'} + + + {node.title} + #{node.id} - -
- ); - })} +
+ )) + : results.map((node, i) => { + const dir = node.path.includes('/') ? node.path.slice(0, node.path.lastIndexOf('/')) : ''; + return ( +
{ + e.preventDefault(); + onSelect(node); + }} + > + + + + + {node.name} + + {dir || node.path} + + +
+ ); + })}
diff --git a/frontend/src/components/chatPanel/fileChips.js b/frontend/src/components/chatPanel/fileChips.js index ec69159..9dc94af 100644 --- a/frontend/src/components/chatPanel/fileChips.js +++ b/frontend/src/components/chatPanel/fileChips.js @@ -5,12 +5,13 @@ // ⟦ref:PATH⟧ — только ссылка (раскрывается в `PATH`) import gitApi from '../../api/gitApi'; +import documentsApi from '../../api/documentsApi'; const OPEN = '⟦'; // ⟦ const CLOSE = '⟧'; // ⟧ -// Глобальный матчер обоих видов токенов. Захватных групп нет — parseToken разбирает детально. -export const TOKEN_RE = new RegExp(`${OPEN}(?:file|ref):[^${CLOSE}]+${CLOSE}`, 'g'); +// Глобальный матчер всех видов токенов. Захватных групп нет — parseToken / parseDocToken разбирают детально. +export const TOKEN_RE = new RegExp(`${OPEN}(?:file|ref|doc):[^${CLOSE}]+${CLOSE}`, 'g'); /** Токен «весь файл / диапазон». */ export function makeToken(path, from, to) { @@ -44,6 +45,25 @@ export function baseName(path) { return i >= 0 ? path.slice(i + 1) : path; } +// ── Doc-токены ──────────────────────────────────────────────────────────────── +// Формат: ⟦doc:ID:TITLE⟧ + +/** Создать токен документа. */ +export function makeDocToken(id, title) { + const safeTitle = String(title).replace(/⟧/g, ''); + return `${OPEN}doc:${id}:${safeTitle}${CLOSE}`; +} + +/** + * Разобрать doc-токен. + * Возвращает { id, title } или null. + */ +export function parseDocToken(token) { + const m = token.match(new RegExp(`^${OPEN}doc:(\\d+):(.*)${CLOSE}$`)); + if (m) return { id: Number(m[1]), title: m[2] }; + return null; +} + // ── Кеш содержимого ────────────────────────────────────────────────────────── const contentCache = new Map(); // key: `path#from-to` → GitFileContent @@ -77,6 +97,18 @@ export async function expandTokensForSend(text) { const blocks = await Promise.all( tokens.map(async (m) => { + const docParsed = parseDocToken(m[0]); + if (docParsed) { + try { + const doc = await documentsApi.getById(docParsed.id); + const title = doc?.title ?? docParsed.title; + const description = doc?.description ?? ''; + return `\n\nДокумент «${title}» (#${docParsed.id}):\n${description}\n`; + } catch { + return `\n\n[Документ #${docParsed.id}: не удалось загрузить]\n`; + } + } + const parsed = parseToken(m[0]); if (!parsed) return m[0]; const { path, from, to, refOnly } = parsed; diff --git a/frontend/src/i18n/locales/en/chat.json b/frontend/src/i18n/locales/en/chat.json index d0bcfb0..c993625 100644 --- a/frontend/src/i18n/locales/en/chat.json +++ b/frontend/src/i18n/locales/en/chat.json @@ -72,6 +72,12 @@ "usePathOnly": "Switch to path-only (no content)", "useFullContent": "Switch to full content" }, + "docInput": { + "hintStart": "Type a document name or ID to search", + "hintQuery": "Search documents: \"{{query}}\"", + "searching": "Searching…", + "empty": "Nothing found" + }, "list": { "newChat": "New chat", "newJiraChat": "JIRA chat", diff --git a/frontend/src/i18n/locales/ru/chat.json b/frontend/src/i18n/locales/ru/chat.json index 3d28438..1b9a62d 100644 --- a/frontend/src/i18n/locales/ru/chat.json +++ b/frontend/src/i18n/locales/ru/chat.json @@ -72,6 +72,12 @@ "usePathOnly": "Только путь (без содержимого)", "useFullContent": "С содержимым" }, + "docInput": { + "hintStart": "Введите название или ID документа", + "hintQuery": "Поиск документов: «{{query}}»", + "searching": "Поиск…", + "empty": "Ничего не найдено" + }, "list": { "newChat": "Новый чат", "newJiraChat": "JIRA чат", From 6eb62ac94472e9a2849b183a2882fbde586fed25 Mon Sep 17 00:00:00 2001 From: Trialiya Date: Sun, 21 Jun 2026 14:11:59 +0300 Subject: [PATCH 2/4] spotlessApply --- .../components/chatPanel/FileChipInput.jsx | 26 +++++++++++++++---- .../chatPanel/FilePickerDropdown.jsx | 23 +++++++++++----- frontend/src/components/chatPanel/Message.jsx | 19 ++++++++++++-- .../src/components/chatPanel/MessageInput.jsx | 16 ++++++++++-- 4 files changed, 69 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/chatPanel/FileChipInput.jsx b/frontend/src/components/chatPanel/FileChipInput.jsx index 17c73ba..634bcb1 100644 --- a/frontend/src/components/chatPanel/FileChipInput.jsx +++ b/frontend/src/components/chatPanel/FileChipInput.jsx @@ -3,7 +3,16 @@ import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import gitApi from '../../api/gitApi'; import documentsApi from '../../api/documentsApi'; -import { makeToken, makeRefToken, makeDocToken, parseToken, parseDocToken, baseName, fetchContent, TOKEN_RE } from './fileChips'; +import { + makeToken, + makeRefToken, + makeDocToken, + parseToken, + parseDocToken, + baseName, + fetchContent, + TOKEN_RE, +} from './fileChips'; import FilePickerDropdown from './FilePickerDropdown'; import useEscape from '../common/useEscape'; import { IconX } from '../../icons'; @@ -159,7 +168,15 @@ const FileChipInput = forwardRef(function FileChipInput( const internalRef = useRef(value); const triggerRef = useRef(null); - const [picker, setPicker] = useState({ open: false, query: '', results: [], loading: false, anchor: null, idx: 0, type: 'file' }); + const [picker, setPicker] = useState({ + open: false, + query: '', + results: [], + loading: false, + anchor: null, + idx: 0, + type: 'file', + }); const [preview, setPreview] = useState(null); // { path, from, to, refOnly, rect, chipEl, data, loading, error } const debounceTimer = useRef(null); @@ -200,9 +217,8 @@ const FileChipInput = forwardRef(function FileChipInput( const controller = new AbortController(); abortRef.current = controller; setPicker((p) => ({ ...p, loading: true })); - const search = type === 'doc' - ? searchDocsAsync(q, controller.signal) - : gitApi.searchFiles(q, 10, controller.signal); + const search = + type === 'doc' ? searchDocsAsync(q, controller.signal) : gitApi.searchFiles(q, 10, controller.signal); search .then((data) => setPicker((p) => ({ ...p, loading: false, results: Array.isArray(data) ? data : [], idx: 0 }))) .catch((err) => { diff --git a/frontend/src/components/chatPanel/FilePickerDropdown.jsx b/frontend/src/components/chatPanel/FilePickerDropdown.jsx index bb7ba4d..78d3144 100644 --- a/frontend/src/components/chatPanel/FilePickerDropdown.jsx +++ b/frontend/src/components/chatPanel/FilePickerDropdown.jsx @@ -16,7 +16,16 @@ import { IconFileText } from '../../icons'; * onSelect(node) — клик/Enter * onDismiss() — закрыть */ -const FilePickerDropdown = ({ results, loading, query, anchorRect, selectedIdx, onSelect, onDismiss, type = 'file' }) => { +const FilePickerDropdown = ({ + results, + loading, + query, + anchorRect, + selectedIdx, + onSelect, + onDismiss, + type = 'file', +}) => { const { t } = useTranslation('chat'); const listRef = useRef(null); @@ -42,8 +51,12 @@ const FilePickerDropdown = ({ results, loading, query, anchorRect, selectedIdx, }; const hintKey = query - ? type === 'doc' ? t('docInput.hintQuery', { query }) : t('fileInput.hintQuery', { query }) - : type === 'doc' ? t('docInput.hintStart') : t('fileInput.hintStart'); + ? type === 'doc' + ? t('docInput.hintQuery', { query }) + : t('fileInput.hintQuery', { query }) + : type === 'doc' + ? t('docInput.hintStart') + : t('fileInput.hintStart'); const searchingLabel = type === 'doc' ? t('docInput.searching') : t('fileInput.searching'); const emptyLabel = type === 'doc' ? t('docInput.empty') : t('fileInput.empty'); @@ -76,9 +89,7 @@ const FilePickerDropdown = ({ results, loading, query, anchorRect, selectedIdx, onSelect(node); }} > - - {node.type === 'folder' ? '📁' : '📋'} - + {node.type === 'folder' ? '📁' : '📋'} {node.title} #{node.id} diff --git a/frontend/src/components/chatPanel/Message.jsx b/frontend/src/components/chatPanel/Message.jsx index 8e3302c..c65b2a1 100644 --- a/frontend/src/components/chatPanel/Message.jsx +++ b/frontend/src/components/chatPanel/Message.jsx @@ -487,10 +487,25 @@ const formatFullDatetime = (ts) => { if (!ts) return null; const date = new Date(ts); if (isNaN(date)) return null; - return date.toLocaleString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' }); + return date.toLocaleString('ru-RU', { + day: 'numeric', + month: 'long', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); }; -const Message = ({ text, sender, toolCalls, toolCallsRunId, preparing, conversationId, onNavigateToDoc, timestamp }) => { +const Message = ({ + text, + sender, + toolCalls, + toolCallsRunId, + preparing, + conversationId, + onNavigateToDoc, + timestamp, +}) => { const { t } = useTranslation('chat'); const [showSource, setShowSource] = useState(false); const messageClass = `message ${sender}`; diff --git a/frontend/src/components/chatPanel/MessageInput.jsx b/frontend/src/components/chatPanel/MessageInput.jsx index aaf5dff..99c1ec1 100644 --- a/frontend/src/components/chatPanel/MessageInput.jsx +++ b/frontend/src/components/chatPanel/MessageInput.jsx @@ -6,7 +6,16 @@ import { expandTokensForSend } from './fileChips'; import { IconSend, IconStop, IconPaperclip } from '../../icons'; // isEmpty — true когда в чате ещё нет сообщений; тогда показываем git-подсказки -const MessageInput = ({ onSend, onStop, disabled, onAttach, isEmpty = false, resetSignal = 0, chatId = null, onTextChange }) => { +const MessageInput = ({ + onSend, + onStop, + disabled, + onAttach, + isEmpty = false, + resetSignal = 0, + chatId = null, + onTextChange, +}) => { const { t } = useTranslation('chat'); const [text, setText] = useState(''); // плоская строка с токенами ⟦file:…⟧ const [sending, setSending] = useState(false); // идёт разворачивание токенов перед отправкой @@ -63,7 +72,10 @@ const MessageInput = ({ onSend, onStop, disabled, onAttach, isEmpty = false, res { setText(v); onTextChange?.(v); }} + onChange={(v) => { + setText(v); + onTextChange?.(v); + }} onSend={handleSubmit} disabled={disabled} placeholder={t('input.placeholder')} From 367c08a25b36d02f3b1c2b3bcf31e11608ed45ea Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 11:20:34 +0000 Subject: [PATCH 3/4] feat: ref-only default insert + content button in file/doc picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enter / click on picker row now inserts a reference chip (📎) for both files (⟦ref:PATH⟧) and documents (⟦docref:ID:TITLE⟧) - Each picker item gains an "📄 содержимое" button on the right that inserts the full-content token (⟦file:PATH⟧ / ⟦doc:ID:TITLE⟧) - Button is hidden until the row is hovered or keyboard-selected to keep the list uncluttered - fileChips: added makeDocRefToken / parseDocRefToken; TOKEN_RE updated to include docref; expandTokensForSend expands docref to a plain «Title» (#ID) mention without fetching description - Footer hint updated: Enter → "вставить ссылку" Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01BA6oSYD68zVgMGt7L2QQiU --- .../components/chatPanel/FileChipInput.jsx | 83 ++++++++++++------- .../chatPanel/FilePickerDropdown.jsx | 51 +++++++++--- .../src/components/chatPanel/chatWindow.css | 33 ++++++++ .../src/components/chatPanel/fileChips.js | 39 +++++++-- frontend/src/i18n/locales/en/chat.json | 2 + frontend/src/i18n/locales/ru/chat.json | 2 + 6 files changed, 164 insertions(+), 46 deletions(-) diff --git a/frontend/src/components/chatPanel/FileChipInput.jsx b/frontend/src/components/chatPanel/FileChipInput.jsx index 634bcb1..6240d14 100644 --- a/frontend/src/components/chatPanel/FileChipInput.jsx +++ b/frontend/src/components/chatPanel/FileChipInput.jsx @@ -7,8 +7,10 @@ import { makeToken, makeRefToken, makeDocToken, + makeDocRefToken, parseToken, parseDocToken, + parseDocRefToken, baseName, fetchContent, TOKEN_RE, @@ -63,33 +65,38 @@ function serialize(root) { return out.replace(/^\n/, ''); } +function makeDocChipEl(token, { id, title }, refOnly) { + const chip = document.createElement('span'); + chip.className = 'file-chip file-chip--doc' + (refOnly ? ' file-chip--ref' : ''); + chip.contentEditable = 'false'; + chip.dataset.token = token; + chip.title = `${title} (#${id})`; + + const icon = document.createElement('span'); + icon.className = 'file-chip__icon'; + icon.textContent = refOnly ? '📎' : '📋'; + + const label = document.createElement('span'); + label.className = 'file-chip__label'; + label.textContent = title; + + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'file-chip__remove'; + remove.textContent = '×'; + remove.tabIndex = -1; + + chip.append(icon, label, remove); + return chip; +} + /** Построить DOM-элемент чипа из строки-токена. */ function makeChipEl(token) { + const docRefParsed = parseDocRefToken(token); + if (docRefParsed) return makeDocChipEl(token, docRefParsed, true); + const docParsed = parseDocToken(token); - if (docParsed) { - const chip = document.createElement('span'); - chip.className = 'file-chip file-chip--doc'; - chip.contentEditable = 'false'; - chip.dataset.token = token; - chip.title = `${docParsed.title} (#${docParsed.id})`; - - const icon = document.createElement('span'); - icon.className = 'file-chip__icon'; - icon.textContent = '📋'; - - const label = document.createElement('span'); - label.className = 'file-chip__label'; - label.textContent = docParsed.title; - - const remove = document.createElement('button'); - remove.type = 'button'; - remove.className = 'file-chip__remove'; - remove.textContent = '×'; - remove.tabIndex = -1; - - chip.append(icon, label, remove); - return chip; - } + if (docParsed) return makeDocChipEl(token, docParsed, false); const parsed = parseToken(token); const path = parsed?.path ?? token; @@ -283,17 +290,16 @@ const FileChipInput = forwardRef(function FileChipInput( setPreview((pv) => (pv ? null : pv)); }, [emitChange, detectTrigger]); - const insertItem = useCallback( - (item) => { + const doInsert = useCallback( + (token) => { const trig = triggerRef.current; const root = editorRef.current; if (!trig || !root) return; - const { node, start, cursorOffset, type } = trig; + const { node, start, cursorOffset } = trig; const before = node.nodeValue.slice(0, start); const after = node.nodeValue.slice(cursorOffset); - const token = type === 'doc' ? makeDocToken(item.id, item.title) : makeToken(item.path); const chip = makeChipEl(token); const tail = document.createTextNode(' ' + after); node.nodeValue = before; @@ -313,6 +319,24 @@ const FileChipInput = forwardRef(function FileChipInput( [dismissPicker, emitChange], ); + // Вставить ссылку (по умолчанию: Enter / клик по строке) + const insertItem = useCallback( + (item) => { + const type = triggerRef.current?.type; + doInsert(type === 'doc' ? makeDocRefToken(item.id, item.title) : makeRefToken(item.path)); + }, + [doInsert], + ); + + // Вставить с содержимым (кнопка в дропдауне) + const insertItemWithContent = useCallback( + (item) => { + const type = triggerRef.current?.type; + doInsert(type === 'doc' ? makeDocToken(item.id, item.title) : makeToken(item.path)); + }, + [doInsert], + ); + const insertTextAtCaret = useCallback((text) => { const sel = window.getSelection(); if (!sel.rangeCount) return; @@ -455,7 +479,7 @@ const FileChipInput = forwardRef(function FileChipInput( const chip = e.target.closest?.('.file-chip'); if (chip) { e.preventDefault(); - if (parseDocToken(chip.dataset.token)) return; + if (parseDocToken(chip.dataset.token) || parseDocRefToken(chip.dataset.token)) return; const parsed = parseToken(chip.dataset.token); if (!parsed) return; const rect = chip.getBoundingClientRect(); @@ -499,6 +523,7 @@ const FileChipInput = forwardRef(function FileChipInput( anchorRect={picker.anchor} selectedIdx={picker.idx} onSelect={insertItem} + onSelectWithContent={insertItemWithContent} onDismiss={dismissPicker} type={picker.type} /> diff --git a/frontend/src/components/chatPanel/FilePickerDropdown.jsx b/frontend/src/components/chatPanel/FilePickerDropdown.jsx index 78d3144..b7f695b 100644 --- a/frontend/src/components/chatPanel/FilePickerDropdown.jsx +++ b/frontend/src/components/chatPanel/FilePickerDropdown.jsx @@ -3,18 +3,19 @@ import { useTranslation } from 'react-i18next'; import { IconFileText } from '../../icons'; /** - * Плавающий список результатов поиска для триггеров `/file` (файлы репозитория) и `/doc` (документы). + * Плавающий список результатов поиска для триггеров `/file` и `/doc`. * Открывается НАД кареткой (композер прижат к низу окна). * * Props: - * results — GitFileNode[] { path, name, size } | DocumentNode[] { id, title, type } - * loading — boolean - * query — string (для пустого состояния) - * anchorRect — DOMRect-like { top, left } каретки - * selectedIdx — number - * type — 'file' | 'doc' - * onSelect(node) — клик/Enter - * onDismiss() — закрыть + * results — GitFileNode[] | DocumentNode[] + * loading — boolean + * query — string + * anchorRect — { top, left } каретки + * selectedIdx — number + * type — 'file' | 'doc' + * onSelect(node) — Enter / клик по строке → вставить ссылку + * onSelectWithContent — клик по кнопке → вставить содержимое + * onDismiss() — закрыть */ const FilePickerDropdown = ({ results, @@ -23,6 +24,7 @@ const FilePickerDropdown = ({ anchorRect, selectedIdx, onSelect, + onSelectWithContent, onDismiss, type = 'file', }) => { @@ -60,6 +62,7 @@ const FilePickerDropdown = ({ const searchingLabel = type === 'doc' ? t('docInput.searching') : t('fileInput.searching'); const emptyLabel = type === 'doc' ? t('docInput.empty') : t('fileInput.empty'); + const contentBtnLabel = t('fileInput.insertContent'); return (
@@ -94,6 +97,20 @@ const FilePickerDropdown = ({ {node.title} #{node.id} +
+ +
)) : results.map((node, i) => { @@ -116,13 +133,27 @@ const FilePickerDropdown = ({ {dir || node.path}
+
+ +
); })}
- ↑↓ {t('fileInput.navigate')} · Enter {t('fileInput.insert')} · Esc{' '} + ↑↓ {t('fileInput.navigate')} · Enter {t('fileInput.insertRef')} · Esc{' '} {t('fileInput.dismiss')}
diff --git a/frontend/src/components/chatPanel/chatWindow.css b/frontend/src/components/chatPanel/chatWindow.css index d81c0d7..3476dde 100644 --- a/frontend/src/components/chatPanel/chatWindow.css +++ b/frontend/src/components/chatPanel/chatWindow.css @@ -733,6 +733,39 @@ display: flex; flex-direction: column; min-width: 0; + flex: 1; +} +.file-picker-item__actions { + margin-left: auto; + display: flex; + align-items: center; + flex-shrink: 0; +} +.file-picker-item__content-btn { + display: flex; + align-items: center; + gap: 3px; + padding: 2px 7px; + border: 1px solid var(--kb-border); + background: var(--kb-white); + border-radius: 3px; + font-size: 0.7rem; + color: var(--kb-muted); + cursor: pointer; + opacity: 0; + pointer-events: none; + transition: opacity 0.1s, color 0.1s, border-color 0.1s, background 0.1s; + white-space: nowrap; +} +.file-picker-item:hover .file-picker-item__content-btn, +.file-picker-item--selected .file-picker-item__content-btn { + opacity: 1; + pointer-events: auto; +} +.file-picker-item__content-btn:hover { + color: var(--kb-accent); + border-color: var(--kb-accent); + background: var(--kb-accent-light); } .file-picker-item__name { font-size: 0.84rem; diff --git a/frontend/src/components/chatPanel/fileChips.js b/frontend/src/components/chatPanel/fileChips.js index 9dc94af..10a51a7 100644 --- a/frontend/src/components/chatPanel/fileChips.js +++ b/frontend/src/components/chatPanel/fileChips.js @@ -10,8 +10,9 @@ import documentsApi from '../../api/documentsApi'; const OPEN = '⟦'; // ⟦ const CLOSE = '⟧'; // ⟧ -// Глобальный матчер всех видов токенов. Захватных групп нет — parseToken / parseDocToken разбирают детально. -export const TOKEN_RE = new RegExp(`${OPEN}(?:file|ref|doc):[^${CLOSE}]+${CLOSE}`, 'g'); +// Глобальный матчер всех видов токенов. Захватных групп нет — parse* разбирают детально. +// docref идёт перед doc, чтобы не срабатывал prefix-match при чтении. +export const TOKEN_RE = new RegExp(`${OPEN}(?:file|ref|docref|doc):[^${CLOSE}]+${CLOSE}`, 'g'); /** Токен «весь файл / диапазон». */ export function makeToken(path, from, to) { @@ -46,16 +47,25 @@ export function baseName(path) { } // ── Doc-токены ──────────────────────────────────────────────────────────────── -// Формат: ⟦doc:ID:TITLE⟧ +// ⟦doc:ID:TITLE⟧ — полное содержимое (описание документа) +// ⟦docref:ID:TITLE⟧ — только упоминание (без содержимого) -/** Создать токен документа. */ +function safeDocTitle(title) { + return String(title).replace(/⟧/g, ''); +} + +/** Токен документа с раскрытием описания при отправке. */ export function makeDocToken(id, title) { - const safeTitle = String(title).replace(/⟧/g, ''); - return `${OPEN}doc:${id}:${safeTitle}${CLOSE}`; + return `${OPEN}doc:${id}:${safeDocTitle(title)}${CLOSE}`; +} + +/** Токен документа — только упоминание (без описания). */ +export function makeDocRefToken(id, title) { + return `${OPEN}docref:${id}:${safeDocTitle(title)}${CLOSE}`; } /** - * Разобрать doc-токен. + * Разобрать doc-токен (с содержимым). * Возвращает { id, title } или null. */ export function parseDocToken(token) { @@ -64,6 +74,16 @@ export function parseDocToken(token) { return null; } +/** + * Разобрать docref-токен (только ссылка). + * Возвращает { id, title } или null. + */ +export function parseDocRefToken(token) { + const m = token.match(new RegExp(`^${OPEN}docref:(\\d+):(.*)${CLOSE}$`)); + if (m) return { id: Number(m[1]), title: m[2] }; + return null; +} + // ── Кеш содержимого ────────────────────────────────────────────────────────── const contentCache = new Map(); // key: `path#from-to` → GitFileContent @@ -97,6 +117,11 @@ export async function expandTokensForSend(text) { const blocks = await Promise.all( tokens.map(async (m) => { + const docRefParsed = parseDocRefToken(m[0]); + if (docRefParsed) { + return `Документ «${docRefParsed.title}» (#${docRefParsed.id})`; + } + const docParsed = parseDocToken(m[0]); if (docParsed) { try { diff --git a/frontend/src/i18n/locales/en/chat.json b/frontend/src/i18n/locales/en/chat.json index 2672f53..8d39d20 100644 --- a/frontend/src/i18n/locales/en/chat.json +++ b/frontend/src/i18n/locales/en/chat.json @@ -73,6 +73,8 @@ "empty": "Nothing found", "navigate": "navigate", "insert": "insert", + "insertRef": "insert link", + "insertContent": "content", "dismiss": "dismiss", "closePreview": "Close preview", "previewError": "Failed to load file", diff --git a/frontend/src/i18n/locales/ru/chat.json b/frontend/src/i18n/locales/ru/chat.json index e1ea80b..1781d72 100644 --- a/frontend/src/i18n/locales/ru/chat.json +++ b/frontend/src/i18n/locales/ru/chat.json @@ -73,6 +73,8 @@ "empty": "Ничего не найдено", "navigate": "выбрать", "insert": "вставить", + "insertRef": "вставить ссылку", + "insertContent": "содержимое", "dismiss": "закрыть", "closePreview": "Закрыть превью", "previewError": "Не удалось загрузить файл", From 8a84cf819899abad7cc28ef7053af0f7c1f8e698 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 11:27:50 +0000 Subject: [PATCH 4/4] fix: hide content button on selected-but-not-hovered picker item Showing the button on both :hover and --selected caused two buttons to be visible simultaneously when mousing over a row other than the keyboard-selected one. Button is now shown on hover only. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01BA6oSYD68zVgMGt7L2QQiU --- frontend/src/components/chatPanel/chatWindow.css | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/components/chatPanel/chatWindow.css b/frontend/src/components/chatPanel/chatWindow.css index 3476dde..79ef646 100644 --- a/frontend/src/components/chatPanel/chatWindow.css +++ b/frontend/src/components/chatPanel/chatWindow.css @@ -757,8 +757,7 @@ transition: opacity 0.1s, color 0.1s, border-color 0.1s, background 0.1s; white-space: nowrap; } -.file-picker-item:hover .file-picker-item__content-btn, -.file-picker-item--selected .file-picker-item__content-btn { +.file-picker-item:hover .file-picker-item__content-btn { opacity: 1; pointer-events: auto; }