docs(page-tree): correct isUnloadedBranch comment — no DnD-guard consumer (#525 review)
The comment falsely listed a 'DnD move guard' consumer; no DnD path routes through the predicate (local DnD/create-page use the raw index-based insert). List the real consumers (handleToggle + realtime insertByPosition/placeByPosition) and note the local raw-insert path as a #525 follow-up. Comment-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@
|
||||
"@casl/react": "5.0.1",
|
||||
"@docmost/editor-ext": "workspace:*",
|
||||
"@docmost/prosemirror-markdown": "workspace:*",
|
||||
"@docmost/token-estimate": "workspace:*",
|
||||
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
|
||||
"@mantine/core": "8.3.18",
|
||||
"@mantine/dates": "8.3.18",
|
||||
|
||||
@@ -256,6 +256,9 @@
|
||||
"Invite link": "Ссылка для приглашения",
|
||||
"Copy": "Копировать",
|
||||
"Copy to space": "Копировать в пространство",
|
||||
"Copy chat": "Копировать чат",
|
||||
"Dock to sidebar": "Закрепить в боковой панели",
|
||||
"Undock": "Открепить",
|
||||
"Copied": "Скопировано",
|
||||
"Failed to export chat": "Не удалось экспортировать чат",
|
||||
"Duplicate": "Дублировать",
|
||||
@@ -285,6 +288,9 @@
|
||||
"Alt text": "Альтернативный текст",
|
||||
"Describe this for accessibility.": "Опишите это для специальных возможностей.",
|
||||
"Add a description": "Добавить описание",
|
||||
"Caption": "Подпись",
|
||||
"Add a caption": "Добавить подпись",
|
||||
"Shown below the image.": "Отображается под изображением.",
|
||||
"Justify": "По ширине",
|
||||
"Merge cells": "Объединить ячейки",
|
||||
"Split cell": "Разделить ячейку",
|
||||
@@ -388,22 +394,6 @@
|
||||
"Quote": "Цитата",
|
||||
"Image": "Изображение",
|
||||
"Audio": "Аудио",
|
||||
"Transcribe": "Транскрибировать",
|
||||
"Transcribing…": "Транскрибация…",
|
||||
"No speech detected": "Речь не распознана",
|
||||
"Transcription failed": "Не удалось распознать речь",
|
||||
"Voice dictation is not configured": "Голосовой ввод не настроен",
|
||||
"Start dictation": "Начать диктовку",
|
||||
"Stop recording": "Остановить запись",
|
||||
"Microphone access denied": "Доступ к микрофону запрещён",
|
||||
"No microphone found": "Микрофон не найден",
|
||||
"Microphone is unavailable or already in use": "Микрофон недоступен или уже используется",
|
||||
"Could not start recording": "Не удалось начать запись",
|
||||
"Audio recording is not available in this browser/context": "Запись аудио недоступна в этом браузере/контексте",
|
||||
"Dictation": "Диктовка",
|
||||
"Dictation becomes available once the page finishes connecting": "Диктовка станет доступна после подключения к документу",
|
||||
"No connection to the collaboration server — dictation unavailable": "Нет связи с сервером совместного редактирования — диктовка недоступна",
|
||||
"This page is read-only": "Страница открыта только для чтения",
|
||||
"Embed PDF": "Встроить PDF",
|
||||
"Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
|
||||
"Embed as PDF": "Встроить как PDF",
|
||||
@@ -419,9 +409,6 @@
|
||||
"Footnote {{number}}": "Сноска {{number}}",
|
||||
"Go to footnote": "Перейти к сноске",
|
||||
"Back to reference": "Вернуться к ссылке",
|
||||
"Back to references": "Вернуться к ссылкам",
|
||||
"Back to reference {{label}}": "Вернуться к ссылке {{label}}",
|
||||
"Empty footnote": "Пустая сноска",
|
||||
"Math inline": "Строчная формула",
|
||||
"Insert inline math equation.": "Вставить математическое выражение в строку.",
|
||||
"Math block": "Блок формулы",
|
||||
@@ -447,6 +434,9 @@
|
||||
"{{count}} command available_other": "Доступно {{count}} команд",
|
||||
"{{count}} result available_one": "Доступен 1 результат",
|
||||
"{{count}} result available_other": "Доступно {{count}} результатов",
|
||||
"{{count}} result found_one": "Найден {{count}} результат",
|
||||
"{{count}} result found_few": "Найдено {{count}} результата",
|
||||
"{{count}} result found_other": "Найдено {{count}} результатов",
|
||||
"Equal columns": "Равные столбцы",
|
||||
"Left sidebar": "Левая боковая панель",
|
||||
"Right sidebar": "Правая боковая панель",
|
||||
@@ -456,6 +446,7 @@
|
||||
"Names do not match": "Названия не совпадают",
|
||||
"Today, {{time}}": "Сегодня, {{time}}",
|
||||
"Yesterday, {{time}}": "Вчера, {{time}}",
|
||||
"now": "сейчас",
|
||||
"Space created successfully": "Пространство успешно создано",
|
||||
"Space updated successfully": "Пространство успешно обновлено",
|
||||
"Space deleted successfully": "Пространство успешно удалено",
|
||||
@@ -559,6 +550,7 @@
|
||||
"Add 2FA method": "Добавить метод 2FA",
|
||||
"Backup codes": "Резервные коды",
|
||||
"Disable": "Отключить",
|
||||
"disabled": "отключено",
|
||||
"Invalid verification code": "Недействительный код подтверждения",
|
||||
"New backup codes have been generated": "Новые резервные коды сгенерированы",
|
||||
"Failed to regenerate backup codes": "Не удалось заново сгенерировать резервные коды",
|
||||
@@ -702,62 +694,6 @@
|
||||
"AI search": "Поиск ИИ",
|
||||
"AI Answer": "Ответ ИИ",
|
||||
"Ask AI": "Спросить ИИ",
|
||||
"AI agent": "AI-агент",
|
||||
"Take a look at the current document": "Посмотри текущий документ",
|
||||
"Start automatically": "Запускать автоматически",
|
||||
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "Когда включено, выбор этой роли отправляет стартовое сообщение и начинает чат. Когда выключено, роль выбирается, а первое сообщение вы вводите сами.",
|
||||
"Launch message": "Стартовое сообщение",
|
||||
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Отправляется автоматически при выборе этой роли. Оставьте пустым, чтобы использовать текст по умолчанию. Игнорируется, когда «Запускать автоматически» выключено.",
|
||||
"AI agent is typing…": "AI-агент печатает…",
|
||||
"{{name}} is typing…": "{{name}} печатает…",
|
||||
"Thinking…": "Думаю…",
|
||||
"Thinking… · {{count}} tokens": "Думаю… · {{count}} токенов",
|
||||
"Thinking… · {{count}} tokens_one": "Думаю… · {{count}} токен",
|
||||
"Thinking… · {{count}} tokens_few": "Думаю… · {{count}} токена",
|
||||
"Thinking… · {{count}} tokens_many": "Думаю… · {{count}} токенов",
|
||||
"Thinking · {{count}} tokens": "Размышления · {{count}} токенов",
|
||||
"Thinking · {{count}} tokens_one": "Размышления · {{count}} токен",
|
||||
"Thinking · {{count}} tokens_few": "Размышления · {{count}} токена",
|
||||
"Thinking · {{count}} tokens_many": "Размышления · {{count}} токенов",
|
||||
"Agent role": "Роль агента",
|
||||
"AI chat": "AI-чат",
|
||||
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
|
||||
"Ask a question about this documentation.": "Задайте вопрос об этой документации.",
|
||||
"Ask a question…": "Задайте вопрос…",
|
||||
"Ask the AI agent anything about your workspace.": "Спросите AI-агента о чём угодно по вашему рабочему пространству.",
|
||||
"Ask the AI agent…": "Спросите AI-агента…",
|
||||
"Copy chat": "Копировать чат",
|
||||
"Dock to sidebar": "Закрепить в боковой панели",
|
||||
"Undock": "Открепить",
|
||||
"Created successfully": "Успешно создано",
|
||||
"Context size / model limit": "Размер контекста / лимит модели",
|
||||
"Context window (tokens)": "Окно контекста (токены)",
|
||||
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
|
||||
"Delete this chat?": "Удалить этот чат?",
|
||||
"Deleted successfully": "Успешно удалено",
|
||||
"AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
|
||||
"AI agent {{name}}": "AI-агент {{name}}",
|
||||
"Failed to delete chat": "Не удалось удалить чат",
|
||||
"Failed to rename chat": "Не удалось переименовать чат",
|
||||
"Failed": "Ошибка",
|
||||
"OK · {{n}}": "OK · {{n}}",
|
||||
"Test": "Тест",
|
||||
"No tools available": "Инструменты недоступны",
|
||||
"Available tools": "Доступные инструменты",
|
||||
"Minimize": "Свернуть",
|
||||
"No chats yet.": "Чатов пока нет.",
|
||||
"Send": "Отправить",
|
||||
"Send when the agent finishes": "Отправить, когда агент закончит",
|
||||
"Queue message": "Поставить в очередь",
|
||||
"Remove queued message": "Убрать из очереди",
|
||||
"Send now": "Отправить сейчас",
|
||||
"Interrupt and send now": "Прервать и отправить сейчас",
|
||||
"Something went wrong": "Что-то пошло не так",
|
||||
"Stop": "Стоп",
|
||||
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
|
||||
"The AI provider is not configured. Ask an administrator to set it up.": "AI-провайдер не настроен. Попросите администратора настроить его.",
|
||||
"Universal assistant": "Универсальный ассистент",
|
||||
"You": "Вы",
|
||||
"AI is thinking...": "ИИ обрабатывает запрос...",
|
||||
"Thinking": "Думаю",
|
||||
"Ask a question...": "Задайте вопрос...",
|
||||
@@ -784,8 +720,40 @@
|
||||
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Управляйте API-ключами для всех пользователей в рабочем пространстве. Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
||||
"View the <anchor>API documentation</anchor> for usage details.": "Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
||||
"View the <anchor>MCP documentation</anchor>.": "Смотрите <anchor>документацию по MCP</anchor>.",
|
||||
"Instructions": "Инструкции",
|
||||
"AI / Models": "ИИ / Модели",
|
||||
"AI / External tools (MCP)": "ИИ / Внешние инструменты (MCP)",
|
||||
"Add server": "Добавить сервер",
|
||||
"Edit server": "Изменить сервер",
|
||||
"Delete server": "Удалить сервер",
|
||||
"Are you sure you want to delete this MCP server?": "Вы уверены, что хотите удалить этот MCP-сервер?",
|
||||
"No external servers configured": "Внешние серверы не настроены",
|
||||
"Server name": "Имя сервера",
|
||||
"Transport": "Транспорт",
|
||||
"URL": "URL",
|
||||
"Authorization header": "Заголовок авторизации",
|
||||
"Tool allowlist": "Список разрешённых инструментов",
|
||||
"Optional. Leave empty to allow all tools the server exposes.": "Необязательно. Оставьте пустым, чтобы разрешить все инструменты, которые предоставляет сервер.",
|
||||
"Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".": "Необязательное указание агенту, как и когда использовать инструменты этого сервера. Добавляется в системный промпт. Инструменты сервера именуются с префиксом «<имя сервера>_*».",
|
||||
"Test": "Тест",
|
||||
"Available tools": "Доступные инструменты",
|
||||
"No tools available": "Инструменты недоступны",
|
||||
"Failed": "Ошибка",
|
||||
"OK · {{n}}": "OK · {{n}}",
|
||||
"Created successfully": "Успешно создано",
|
||||
"Deleted successfully": "Успешно удалено",
|
||||
"Clear": "Очистить",
|
||||
"Provider": "Провайдер",
|
||||
"•••• set": "•••• задан",
|
||||
"Clear key": "Очистить ключ",
|
||||
"Base URL": "Базовый URL",
|
||||
"Chat model": "Модель чата",
|
||||
"Embedding model": "Модель эмбеддингов",
|
||||
"System message": "Системное сообщение",
|
||||
"A built-in safety framework is always appended.": "Встроенный набор правил безопасности всегда добавляется автоматически.",
|
||||
"Test connection": "Проверить соединение",
|
||||
"Connection successful": "Соединение установлено",
|
||||
"Connection failed": "Не удалось установить соединение",
|
||||
"Only workspace admins can manage AI provider settings.": "Управлять настройками провайдера ИИ могут только администраторы рабочего пространства.",
|
||||
"Sources": "Источники",
|
||||
"AI Answers not available for attachments": "Ответы ИИ недоступны для вложений",
|
||||
"No answer available": "Ответ недоступен",
|
||||
@@ -1013,6 +981,7 @@
|
||||
"Try again": "Попробовать снова",
|
||||
"Untitled chat": "Чат без названия",
|
||||
"No document": "Без документа",
|
||||
"You": "Вы",
|
||||
"What can I help you with?": "Чем я могу вам помочь?",
|
||||
"Are you sure you want to revoke this {{credential}}": "Вы уверены, что хотите отозвать этот {{credential}}",
|
||||
"Automatically provision users and groups from your identity provider via SCIM.": "Автоматически предоставляйте доступ пользователям и группам из вашего провайдера удостоверений через SCIM.",
|
||||
@@ -1041,6 +1010,9 @@
|
||||
"Page menu": "Меню страницы",
|
||||
"Expand": "Развернуть",
|
||||
"Collapse": "Свернуть",
|
||||
"Expand all": "Развернуть все",
|
||||
"Collapse all": "Свернуть все",
|
||||
"Couldn't expand the tree: {{reason}}": "Не удалось развернуть дерево: {{reason}}",
|
||||
"Comment menu": "Меню комментария",
|
||||
"Group menu": "Меню группы",
|
||||
"Show hidden breadcrumbs": "Показать скрытые хлебные крошки",
|
||||
@@ -1077,7 +1049,7 @@
|
||||
"Search pages and spaces...": "Поиск страниц и пространств...",
|
||||
"No results found": "Результаты не найдены",
|
||||
"You don't have permission to create pages here": "У вас нет прав на создание страниц здесь",
|
||||
"Chat menu": "Меню чата",
|
||||
"Chat menu for {{title}}": "Меню чата для {{title}}",
|
||||
"API key menu": "Меню API-ключа",
|
||||
"Jump to comment selection": "Перейти к выбору комментария",
|
||||
"Slash commands": "Команды со слешем",
|
||||
@@ -1131,6 +1103,9 @@
|
||||
"Undo": "Отменить",
|
||||
"Redo": "Повторить",
|
||||
"Backlinks": "Обратные ссылки",
|
||||
"Back to references": "Вернуться к ссылкам",
|
||||
"Back to reference {{label}}": "Вернуться к ссылке {{label}}",
|
||||
"Empty footnote": "Пустая сноска",
|
||||
"Last updated by": "Последний изменивший",
|
||||
"Last updated": "Последнее обновление",
|
||||
"Stats": "Статистика",
|
||||
@@ -1164,6 +1139,7 @@
|
||||
"Page title": "Заголовок страницы",
|
||||
"Page content": "Содержимое страницы",
|
||||
"Member actions": "Действия с участником",
|
||||
"Member actions for {{name}}": "Действия с участником {{name}}",
|
||||
"Toggle password visibility": "Переключить видимость пароля",
|
||||
"Send comment": "Отправить комментарий",
|
||||
"Token actions": "Действия с токеном",
|
||||
@@ -1183,11 +1159,187 @@
|
||||
"Removed from favorites": "Удалено из избранного",
|
||||
"Added {{name}} to favorites": "{{name}} добавлено в избранное",
|
||||
"Removed {{name}} from favorites": "{{name}} удалено из избранного",
|
||||
"Label added": "Метка добавлена",
|
||||
"Label removed": "Метка удалена",
|
||||
"Image updated": "Изображение обновлено",
|
||||
"Unsupported image type": "Неподдерживаемый тип изображения",
|
||||
"Member deactivated": "Участник деактивирован",
|
||||
"Member activated": "Участник активирован",
|
||||
"Name is required": "Укажите имя",
|
||||
"Name must be 40 characters or fewer": "Имя должно содержать не более 40 символов",
|
||||
"Group name must be at least 2 characters": "Название группы должно содержать не менее 2 символов",
|
||||
"Group name must be 100 characters or fewer": "Название группы должно содержать не более 100 символов",
|
||||
"Description must be 500 characters or fewer": "Описание должно содержать не более 500 символов",
|
||||
"Invalid invitation link": "Недействительная ссылка-приглашение",
|
||||
"Page menu for {{name}}": "Меню страницы для {{name}}",
|
||||
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
|
||||
"AI chat": "AI-чат",
|
||||
"Ask a question about this documentation.": "Задайте вопрос об этой документации.",
|
||||
"Ask a question…": "Задайте вопрос…",
|
||||
"Thinking…": "Думаю…",
|
||||
"Thinking… · {{count}} tokens": "Думаю… · {{count}} токенов",
|
||||
"Thinking… · {{count}} tokens_one": "Думаю… · {{count}} токен",
|
||||
"Thinking… · {{count}} tokens_few": "Думаю… · {{count}} токена",
|
||||
"Thinking… · {{count}} tokens_many": "Думаю… · {{count}} токенов",
|
||||
"Thinking… · {{count}} tokens_other": "Думаю… · {{count}} токенов",
|
||||
"Thinking · {{count}} tokens": "Размышления · {{count}} токенов",
|
||||
"Thinking · {{count}} tokens_one": "Размышления · {{count}} токен",
|
||||
"Thinking · {{count}} tokens_few": "Размышления · {{count}} токена",
|
||||
"Thinking · {{count}} tokens_many": "Размышления · {{count}} токенов",
|
||||
"Thinking · {{count}} tokens_other": "Размышления · {{count}} токенов",
|
||||
"The assistant is unavailable right now. Please try again.": "Ассистент сейчас недоступен. Попробуйте ещё раз.",
|
||||
"Public share assistant": "Ассистент публичного доступа",
|
||||
"Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.": "Позвольте анонимным посетителям публичных ссылок обращаться к ИИ-ассистенту в рамках страниц этой публикации. Токены оплачиваете вы.",
|
||||
"Public assistant model": "Модель публичного ассистента",
|
||||
"Defaults to the chat model": "По умолчанию используется модель чата",
|
||||
"Optional cheaper model id for the public assistant. Empty uses the chat model above.": "Необязательный более дешёвый идентификатор модели для публичного ассистента. Если пусто, используется модель чата выше.",
|
||||
"Assistant identity": "Личность ассистента",
|
||||
"Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.": "Выберите роль агента, чью личность примет публичный ассистент. Правила безопасности всегда остаются в силе.",
|
||||
"Built-in assistant persona": "Встроенная личность ассистента",
|
||||
"Minimize": "Свернуть",
|
||||
"Context size / model limit": "Размер контекста / лимит модели",
|
||||
"Context window (tokens)": "Окно контекста (токены)",
|
||||
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
|
||||
"AI agent": "AI-агент",
|
||||
"Take a look at the current document": "Посмотри текущий документ",
|
||||
"AI agent is typing…": "AI-агент печатает…",
|
||||
"{{name}} is typing…": "{{name}} печатает…",
|
||||
"Send": "Отправить",
|
||||
"Send when the agent finishes": "Отправить, когда агент закончит",
|
||||
"Queue message": "Поставить в очередь",
|
||||
"Remove queued message": "Убрать из очереди",
|
||||
"Send now": "Отправить сейчас",
|
||||
"Interrupt and send now": "Прервать и отправить сейчас",
|
||||
"Stop": "Стоп",
|
||||
"Response stopped.": "Ответ остановлен.",
|
||||
"Connection lost — the answer was interrupted.": "Соединение потеряно — ответ был прерван.",
|
||||
"Response stopped (manually or the connection dropped).": "Ответ остановлен (вручную или из-за разрыва соединения).",
|
||||
"Chat menu": "Меню чата",
|
||||
"No chats yet.": "Чатов пока нет.",
|
||||
"Delete this chat?": "Удалить этот чат?",
|
||||
"Ask the AI agent…": "Спросите AI-агента…",
|
||||
"Ask the AI agent anything about your workspace.": "Спросите AI-агента о чём угодно по вашему рабочему пространству.",
|
||||
"Failed to rename chat": "Не удалось переименовать чат",
|
||||
"Failed to delete chat": "Не удалось удалить чат",
|
||||
"Something went wrong": "Что-то пошло не так",
|
||||
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
|
||||
"The AI provider is not configured. Ask an administrator to set it up.": "AI-провайдер не настроен. Попросите администратора настроить его.",
|
||||
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
|
||||
"Searched pages": "Поиск по страницам",
|
||||
"Read page": "Прочитана страница",
|
||||
"Created page": "Создана страница",
|
||||
"Updated page": "Обновлена страница",
|
||||
"Renamed page": "Переименована страница",
|
||||
"Moved page": "Перемещена страница",
|
||||
"Deleted page (to trash)": "Удалена страница (в корзину)",
|
||||
"Commented": "Добавлен комментарий",
|
||||
"Resolved comment": "Комментарий решён",
|
||||
"Ran tool {{name}}": "Выполнен инструмент {{name}}",
|
||||
"AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
|
||||
"AI agent {{name}}": "AI-агент {{name}}",
|
||||
"Endpoints": "Эндпоинты",
|
||||
"where we fetch models": "откуда мы получаем модели",
|
||||
"All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.": "Все эндпоинты совместимы с OpenAI. Укажите в базовом URL адрес OpenAI, OpenRouter, локального Ollama или любого self-hosted сервера.",
|
||||
"Chat / LLM": "Чат / LLM",
|
||||
"root": "корневой",
|
||||
"Semantic search": "Семантический поиск",
|
||||
"Voice / STT": "Голос / STT",
|
||||
"Voice dictation": "Голосовой ввод",
|
||||
"Streaming dictation": "Потоковый голосовой ввод",
|
||||
"Transcribe as you speak, cutting on pauses": "Транскрибирование по мере речи, с разбивкой на паузах",
|
||||
"Voice dictation is not available yet.": "Голосовой ввод пока недоступен.",
|
||||
"Test endpoint": "Проверить эндпоинт",
|
||||
"Save and test": "Сохранить и проверить",
|
||||
"Save endpoints": "Сохранить эндпоинты",
|
||||
"Configured and enabled": "Настроено и включено",
|
||||
"Configured but disabled": "Настроено, но отключено",
|
||||
"Enabled but not configured": "Включено, но не настроено",
|
||||
"Not configured": "Не настроено",
|
||||
"External tools": "Внешние инструменты",
|
||||
"Gitmost as MCP client": "Gitmost как MCP-клиент",
|
||||
"Servers the agent calls out to.": "Серверы, к которым обращается агент.",
|
||||
"MCP server": "MCP-сервер",
|
||||
"expose the workspace": "открыть доступ к рабочему пространству",
|
||||
"Enable MCP server": "Включить MCP-сервер",
|
||||
"Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.": "Открывает рабочее пространство как MCP-сервер по адресу /mcp — это предоставляет возможность, а не потребляет модель.",
|
||||
"Resolves to {{url}}": "Разрешается в {{url}}",
|
||||
"Model": "Модель",
|
||||
"Done": "Готово",
|
||||
"shared prompt · safety framework appended automatically": "общий промпт · правила безопасности добавляются автоматически",
|
||||
"/v1/chat/completions · root endpoint — Embeddings and Voice inherit its URL and key": "/v1/chat/completions · корневой эндпоинт — Эмбеддинги и Голос наследуют его URL и ключ",
|
||||
"/v1/embeddings · embeds pages so semantic search can find them": "/v1/embeddings · создаёт эмбеддинги страниц, чтобы их находил семантический поиск",
|
||||
"/v1/audio/transcriptions · works with local whisper (speaches / faster-whisper-server)": "/v1/audio/transcriptions · работает с локальным whisper (speaches / faster-whisper-server)",
|
||||
"Vector search · requires pgvector": "Векторный поиск · требуется pgvector",
|
||||
"Embedding API key": "API-ключ для эмбеддингов",
|
||||
"Embeddings": "Эмбеддинги",
|
||||
"Leave empty to use the chat API key": "Оставьте пустым, чтобы использовать API-ключ чата",
|
||||
"Leave empty to use the chat base URL": "Оставьте пустым, чтобы использовать базовый URL чата",
|
||||
"Reindex now": "Переиндексировать сейчас",
|
||||
"Start dictation": "Начать диктовку",
|
||||
"Stop recording": "Остановить запись",
|
||||
"Transcribing…": "Транскрибация…",
|
||||
"Microphone access denied": "Доступ к микрофону запрещён",
|
||||
"No microphone found": "Микрофон не найден",
|
||||
"Could not start recording": "Не удалось начать запись",
|
||||
"Transcription failed": "Не удалось распознать речь",
|
||||
"Transcribe": "Транскрибировать",
|
||||
"No speech detected": "Речь не распознана",
|
||||
"Voice dictation is not configured": "Голосовой ввод не настроен",
|
||||
"Microphone is unavailable or already in use": "Микрофон недоступен или уже используется",
|
||||
"Audio recording is not available in this browser/context": "Запись аудио недоступна в этом браузере/контексте",
|
||||
"Dictation": "Диктовка",
|
||||
"Dictation becomes available once the page finishes connecting": "Диктовка станет доступна после подключения к документу",
|
||||
"No connection to the collaboration server — dictation unavailable": "Нет связи с сервером совместного редактирования — диктовка недоступна",
|
||||
"This page is read-only": "Страница открыта только для чтения",
|
||||
"Request format": "Формат запроса",
|
||||
"How transcription requests are sent to the endpoint": "Как запросы на транскрибирование отправляются на эндпоинт",
|
||||
"OpenAI-compatible (multipart/form-data)": "Совместимо с OpenAI (multipart/form-data)",
|
||||
"OpenRouter (JSON, base64 audio)": "OpenRouter (JSON, аудио в base64)",
|
||||
"Dictation language": "Язык диктовки",
|
||||
"Auto-detect": "Автоопределение",
|
||||
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
|
||||
"Agent role": "Роль агента",
|
||||
"Universal assistant": "Универсальный ассистент",
|
||||
"Add role": "Добавить роль",
|
||||
"Edit role": "Изменить роль",
|
||||
"Role name": "Название роли",
|
||||
"e.g. Proofreader": "напр. Корректор",
|
||||
"Optional. Shown as the chat badge.": "Необязательно. Отображается как значок чата.",
|
||||
"Optional. A short note about what this role does.": "Необязательно. Краткое описание того, что делает эта роль.",
|
||||
"Instructions": "Инструкции",
|
||||
"The built-in safety framework is always added automatically.": "Встроенный набор правил безопасности всегда добавляется автоматически.",
|
||||
"Model provider override": "Переопределение провайдера модели",
|
||||
"Optional. Defaults to the workspace provider.": "Необязательно. По умолчанию используется провайдер рабочего пространства.",
|
||||
"Model override": "Переопределение модели",
|
||||
"Optional. Defaults to the workspace model.": "Необязательно. По умолчанию используется модель рабочего пространства.",
|
||||
"e.g. gpt-4o-mini": "напр. gpt-4o-mini",
|
||||
"If you choose a different provider, it must already be configured in AI settings.": "Если вы выбираете другого провайдера, он уже должен быть настроен в настройках ИИ.",
|
||||
"Start automatically": "Запускать автоматически",
|
||||
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "Когда включено, выбор этой роли отправляет стартовое сообщение и начинает чат. Когда выключено, роль выбирается, а первое сообщение вы вводите сами.",
|
||||
"Launch message": "Стартовое сообщение",
|
||||
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Отправляется автоматически при выборе этой роли. Оставьте пустым, чтобы использовать текст по умолчанию. Игнорируется, когда «Запускать автоматически» выключено.",
|
||||
"Agent roles": "Роли агента",
|
||||
"Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.": "Многоразовые пресеты, определяющие поведение агента (и, при желании, его модель). Выбираются при запуске нового чата.",
|
||||
"No roles configured": "Роли не настроены",
|
||||
"Delete role": "Удалить роль",
|
||||
"Are you sure you want to delete this role?": "Вы уверены, что хотите удалить эту роль?",
|
||||
"HTML embed": "HTML-вставка",
|
||||
"Edit HTML embed": "Изменить HTML-вставку",
|
||||
"HTML embed is disabled in this workspace": "HTML-вставки отключены в этом рабочем пространстве",
|
||||
"Click to add HTML / CSS / JS": "Нажмите, чтобы добавить HTML / CSS / JS",
|
||||
"This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.": "Этот HTML/CSS/JS выполняется в изолированном фрейме и не имеет доступа к сессии, cookie или API просматривающего.",
|
||||
"<script>...</script>": "<script>...</script>",
|
||||
"Height (px, blank = auto)": "Высота (px, пусто = авто)",
|
||||
"advanced": "дополнительно",
|
||||
"Enable HTML embed": "Включить HTML-вставки",
|
||||
"Allow members to insert raw HTML/CSS/JavaScript blocks. The block renders in a sandboxed frame and cannot access the viewer's session, cookies, or API. Off by default.": "Разрешить участникам вставлять блоки с необработанным HTML/CSS/JavaScript. Блок отображается в изолированном фрейме и не имеет доступа к сессии, cookie или API просматривающего. По умолчанию выключено.",
|
||||
"When enabled, any member can insert an HTML embed block. The toggle just enables or disables the block type workspace-wide.": "Когда включено, любой участник может вставить блок HTML-вставки. Переключатель просто включает или отключает этот тип блока во всём рабочем пространстве.",
|
||||
"Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.": "Вставки выполняются в изолированном iframe с отдельным источником, поэтому они не могут читать или изменять страницу, в которую встроены.",
|
||||
"Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.": "Отключение этой опции скрывает существующие вставки (они отображаются как отключённая заглушка) и прекращает их показ на публичных страницах.",
|
||||
"Analytics / tracker": "Аналитика / трекер",
|
||||
"Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Вставляется дословно в <head> только ПУБЛИЧНЫХ страниц (тот же источник). Для сниппетов аналитики (Google Analytics, Яндекс.Метрика и т. п.). Только для администраторов.",
|
||||
"Go to login page": "Перейти на страницу входа",
|
||||
"Move to space": "Переместить в пространство",
|
||||
"Float left (wrap text)": "Обтекание слева",
|
||||
"Float right (wrap text)": "Обтекание справа",
|
||||
"Inline (side by side)": "В ряд",
|
||||
@@ -1199,6 +1351,7 @@
|
||||
"Showing {{count}} subpages_one": "Показано {{count}} подстраница",
|
||||
"Showing {{count}} subpages_few": "Показано {{count}} подстраницы",
|
||||
"Showing {{count}} subpages_many": "Показано {{count}} подстраниц",
|
||||
"Showing {{count}} subpages_other": "Показано {{count}} подстраниц",
|
||||
"Protocol": "Протокол",
|
||||
"How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning",
|
||||
"OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)",
|
||||
@@ -1268,7 +1421,6 @@
|
||||
"Retry": "Повторить",
|
||||
"The catalog is empty": "Каталог пуст",
|
||||
"No role bundles are published for this language yet. Try switching the content language.": "Для этого языка ещё не опубликовано ни одного набора ролей. Попробуйте сменить язык контента.",
|
||||
"No roles configured": "Роли не настроены",
|
||||
"Already up to date": "Уже актуальна",
|
||||
"Updated to the latest version": "Обновлено до последней версии",
|
||||
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isChunkLoadError } from "./chunk-load-error-boundary";
|
||||
import { isChunkLoadError, shouldAutoReload } from "./chunk-load-error-boundary";
|
||||
|
||||
// The detector decides whether a caught render error is a stale-deploy chunk-404
|
||||
// (→ auto-reload to fetch the new manifest) vs a genuine app error (→ generic
|
||||
@@ -35,3 +35,31 @@ describe("isChunkLoadError", () => {
|
||||
expect(isChunkLoadError(err)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The window gate replaces the old one-shot flag: it must permit recovery across
|
||||
// several deploys in one tab (each > window apart) while still stopping an infinite
|
||||
// reload loop when a lazy chunk is permanently broken (a second failure < window).
|
||||
describe("shouldAutoReload", () => {
|
||||
const WINDOW = 5 * 60 * 1000;
|
||||
const NOW = 1_000_000_000_000;
|
||||
|
||||
it("allows a reload when we have never auto-reloaded", () => {
|
||||
expect(shouldAutoReload(NOW, null, WINDOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows a reload when the last one was 6 minutes ago (outside the window)", () => {
|
||||
expect(shouldAutoReload(NOW, NOW - 6 * 60 * 1000, WINDOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks a reload when the last one was 1 minute ago (inside the window)", () => {
|
||||
expect(shouldAutoReload(NOW, NOW - 1 * 60 * 1000, WINDOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks a reload exactly at the window boundary (not strictly older)", () => {
|
||||
expect(shouldAutoReload(NOW, NOW - WINDOW, WINDOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a reload when the stored timestamp is unparseable (NaN)", () => {
|
||||
expect(shouldAutoReload(NOW, NaN, WINDOW)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,25 @@ import { ReactNode } from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { Button, Center, Stack, Text } from "@mantine/core";
|
||||
|
||||
const RELOAD_FLAG = "chunk-reload-attempted";
|
||||
// sessionStorage key holding the epoch-ms timestamp of the last automatic reload.
|
||||
const RELOAD_AT_KEY = "chunk-reload-at";
|
||||
// Allow at most one automatic reload per this window. A stale-deploy 404 is cured
|
||||
// by a single reload, so anything inside the window is treated as a reload loop
|
||||
// (permanently-broken chunk) and falls through to the manual UI. A window (rather
|
||||
// than a one-shot flag) lets a SECOND deploy in the same tab's lifetime recover too.
|
||||
const RELOAD_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
// Pure window decision, unit-tested in isolation: auto-reload only if we have never
|
||||
// auto-reloaded (lastReloadAt null/NaN) or the last one was strictly older than the
|
||||
// window. Anything inside the window is suppressed to break an infinite reload loop.
|
||||
export function shouldAutoReload(
|
||||
now: number,
|
||||
lastReloadAt: number | null,
|
||||
windowMs: number,
|
||||
): boolean {
|
||||
if (lastReloadAt === null || Number.isNaN(lastReloadAt)) return true;
|
||||
return now - lastReloadAt > windowMs;
|
||||
}
|
||||
|
||||
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
|
||||
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
|
||||
@@ -24,12 +42,16 @@ export function isChunkLoadError(error: unknown): boolean {
|
||||
function handleError(error: unknown) {
|
||||
if (!isChunkLoadError(error)) return;
|
||||
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and
|
||||
// the new chunk manifest. Auto-reload once, guarding against a reload loop
|
||||
// (e.g. a genuinely missing chunk) with a one-shot sessionStorage flag. If the
|
||||
// flag is already set we fall through to the manual recovery UI below.
|
||||
// the new chunk manifest. Auto-reload at most once per RELOAD_WINDOW_MS: this
|
||||
// recovers across multiple deploys in a single tab's lifetime, yet a
|
||||
// permanently-broken lazy chunk (which would loop) is stopped after the first
|
||||
// reload and falls through to the manual recovery UI below.
|
||||
try {
|
||||
if (sessionStorage.getItem(RELOAD_FLAG)) return;
|
||||
sessionStorage.setItem(RELOAD_FLAG, "1");
|
||||
const raw = sessionStorage.getItem(RELOAD_AT_KEY);
|
||||
const lastReloadAt = raw === null ? null : Number.parseInt(raw, 10);
|
||||
const now = Date.now();
|
||||
if (!shouldAutoReload(now, lastReloadAt, RELOAD_WINDOW_MS)) return;
|
||||
sessionStorage.setItem(RELOAD_AT_KEY, String(now));
|
||||
} catch {
|
||||
// sessionStorage unavailable (private mode / disabled): skip the automatic
|
||||
// reload rather than risk an unguarded loop; the fallback UI still recovers.
|
||||
|
||||
@@ -6,10 +6,13 @@ describe("estimateTokens", () => {
|
||||
expect(estimateTokens("")).toBe(0);
|
||||
});
|
||||
|
||||
it("ceils chars/4 so any non-empty text is at least 1 token", () => {
|
||||
// #490: migrated onto the shared @docmost/token-estimate module (chars/2.5, up
|
||||
// from the old client-only chars/4) so the client counter and the server replay
|
||||
// budgeter can never diverge.
|
||||
it("ceils chars/2.5 so any non-empty text is at least 1 token", () => {
|
||||
expect(estimateTokens("a")).toBe(1);
|
||||
expect(estimateTokens("abcd")).toBe(1);
|
||||
expect(estimateTokens("abcde")).toBe(2);
|
||||
expect(estimateTokens("12345678")).toBe(2);
|
||||
expect(estimateTokens("ab")).toBe(1);
|
||||
expect(estimateTokens("abcde")).toBe(2); // 5 / 2.5 = 2
|
||||
expect(estimateTokens("x".repeat(10))).toBe(4); // 10 / 2.5 = 4
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,18 +2,10 @@
|
||||
* Rough client-side token estimation for AI-chat UI affordances.
|
||||
*
|
||||
* No provider streams exact per-token usage mid-stream, so any in-flight figure
|
||||
* is a CLIENT ESTIMATE (chars/≈4 heuristic). Pure + unit-testable: it never runs
|
||||
* a real BPE tokenizer (that would be O(n²) on the hot path, bloat the bundle,
|
||||
* and be wrong for Gemini/Ollama anyway). Used by the in-body reasoning counter
|
||||
* ("Thinking · N tokens").
|
||||
* is a CLIENT ESTIMATE. This re-exports the SHARED estimator from
|
||||
* `@docmost/token-estimate` (chars/2.5) so the in-body counter and the server's
|
||||
* replay budgeter use the SAME heuristic — two divergent estimators would mean
|
||||
* "the badge shows 60%" while "the budgeter already trimmed" (#490). Used by the
|
||||
* in-body reasoning counter ("Thinking · N tokens").
|
||||
*/
|
||||
|
||||
/**
|
||||
* Rough token estimate for a piece of text using the standard chars/≈4 heuristic.
|
||||
* Returns 0 for empty/whitespace-free-of-content input, and ceils so any
|
||||
* non-empty text counts as at least one token.
|
||||
*/
|
||||
export function estimateTokens(text: string): number {
|
||||
if (!text) return 0;
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
export { estimateTokens } from "@docmost/token-estimate";
|
||||
|
||||
@@ -89,6 +89,23 @@ describe("describeChatError", () => {
|
||||
expect(view.title).not.toBe("AI provider not configured");
|
||||
});
|
||||
|
||||
it("classifies a token-degeneration abort under the SAME 'Response stopped.' marker the live view shows (#495)", () => {
|
||||
// The exact reason the server persists in metadata.error on a degeneration
|
||||
// abort (ai-chat.service OUTPUT_DEGENERATION_ERROR). Live, this event shows
|
||||
// the neutral "Response stopped." notice; the persisted banner MUST match it
|
||||
// so live and refetch never disagree.
|
||||
const view = describeChatError(
|
||||
"Output degeneration detected (repeated token loop)",
|
||||
t,
|
||||
);
|
||||
expect(view.title).toBe("Response stopped.");
|
||||
expect(view.detail).toBe(
|
||||
"The answer was stopped automatically because the model fell into a repeated output loop.",
|
||||
);
|
||||
// Regression guard: it must NOT fall through to the generic heading.
|
||||
expect(view.title).not.toBe("Something went wrong");
|
||||
});
|
||||
|
||||
it("classifies a dropped connection (ECONNRESET) as a lost-connection error", () => {
|
||||
expect(
|
||||
describeChatError("Cannot connect to API: read ECONNRESET", t).title,
|
||||
|
||||
@@ -77,6 +77,22 @@ export function describeChatError(
|
||||
};
|
||||
}
|
||||
|
||||
// Our own token-degeneration abort (#444): the server aborts a runaway
|
||||
// repetition loop and persists this exact reason in metadata.error. LIVE, the
|
||||
// same abort surfaces as the neutral "Response stopped." notice (the client
|
||||
// cannot tell it from a manual Stop mid-stream), so the persisted banner must
|
||||
// read the SAME "Response stopped." marker — otherwise the live view and a
|
||||
// later refetch show two different texts for one event. The detail explains the
|
||||
// loop-guard cause without contradicting the shared heading.
|
||||
if (/output degeneration detected|repeated token loop/i.test(msg)) {
|
||||
return {
|
||||
title: t("Response stopped."),
|
||||
detail: t(
|
||||
"The answer was stopped automatically because the model fell into a repeated output loop.",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (/"statusCode"\s*:\s*403\b/.test(msg)) {
|
||||
return {
|
||||
title: t("AI chat is disabled"),
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import * as Y from "yjs";
|
||||
import { yHistoryAvailability } from "./use-toolbar-state.ts";
|
||||
|
||||
// Undo/redo availability is derived from the Yjs UndoManager's PRIVATE
|
||||
// `undoStack` / `redoStack` fields (see use-toolbar-state.ts for why we read the
|
||||
// stack lengths directly instead of the expensive `editor.can().undo()` dry-run).
|
||||
// These tests lock in the behavior AND pin the library shape so a yjs / y-undo
|
||||
// upgrade that renames/restructures those internals fails loudly here rather than
|
||||
// silently enabling/disabling the toolbar buttons in production.
|
||||
describe("yHistoryAvailability", () => {
|
||||
it("reports availability from the stack lengths", () => {
|
||||
expect(yHistoryAvailability({ undoStack: [], redoStack: [] })).toEqual({
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
});
|
||||
expect(
|
||||
yHistoryAvailability({ undoStack: [{}], redoStack: [] }),
|
||||
).toEqual({ canUndo: true, canRedo: false });
|
||||
expect(
|
||||
yHistoryAvailability({ undoStack: [{}], redoStack: [{}, {}] }),
|
||||
).toEqual({ canUndo: true, canRedo: true });
|
||||
});
|
||||
|
||||
it("returns null when the private stack shape is unrecognized (upgrade guard)", () => {
|
||||
// Simulates a yjs / y-undo upgrade that renames or restructures the private
|
||||
// fields: the caller then falls back to the safe prosemirror-history default
|
||||
// instead of throwing on `.length` of undefined or reading garbage.
|
||||
expect(yHistoryAvailability(undefined)).toBeNull();
|
||||
expect(yHistoryAvailability(null)).toBeNull();
|
||||
expect(yHistoryAvailability({})).toBeNull();
|
||||
expect(yHistoryAvailability({ undoStack: 5, redoStack: 5 })).toBeNull();
|
||||
// Only one stack present (partial rename) is still not trusted.
|
||||
expect(yHistoryAvailability({ undoStack: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it("pin-test: a real yjs UndoManager still exposes undoStack/redoStack arrays", () => {
|
||||
const doc = new Y.Doc();
|
||||
const text = doc.getText("prosemirror");
|
||||
const undoManager = new Y.UndoManager(text);
|
||||
|
||||
// Fresh manager: both stacks empty -> nothing to undo/redo.
|
||||
expect(yHistoryAvailability(undoManager)).toEqual({
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
});
|
||||
|
||||
// A tracked edit must push onto the private undoStack. If a future yjs
|
||||
// renames these fields, yHistoryAvailability(undoManager) returns null and
|
||||
// the expectation below fails loudly.
|
||||
text.insert(0, "hello");
|
||||
undoManager.stopCapturing();
|
||||
expect(yHistoryAvailability(undoManager)).toEqual({
|
||||
canUndo: true,
|
||||
canRedo: false,
|
||||
});
|
||||
|
||||
// Undoing moves the item to the redoStack -> redo becomes available.
|
||||
undoManager.undo();
|
||||
expect(yHistoryAvailability(undoManager)).toEqual({
|
||||
canUndo: false,
|
||||
canRedo: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,30 @@ export interface ToolbarState {
|
||||
// When neither history backend is installed (the pre-sync static editor —
|
||||
// mainExtensions only, undoRedo disabled), both fall through to 0 -> false,
|
||||
// matching the previous `safeCan` behavior.
|
||||
// Reads the Yjs UndoManager's undo/redo availability from its stack lengths.
|
||||
//
|
||||
// `undoStack` / `redoStack` are PRIVATE y-undo / yjs internals, so we touch them
|
||||
// defensively: a yjs or y-undo upgrade that renames or restructures these fields
|
||||
// must not silently mis-drive the toolbar buttons (nor throw on `.length` of
|
||||
// `undefined`). We only trust them when they are actually arrays; otherwise this
|
||||
// returns null and the caller falls back to a safe default. The pin-test in
|
||||
// use-toolbar-state.test.ts asserts the current library shape, so an upgrade that
|
||||
// breaks this contract fails loudly there instead of failing silently in the UI.
|
||||
export function yHistoryAvailability(
|
||||
undoManager: unknown,
|
||||
): { canUndo: boolean; canRedo: boolean } | null {
|
||||
if (!undoManager || typeof undoManager !== "object") return null;
|
||||
const { undoStack, redoStack } = undoManager as {
|
||||
undoStack?: unknown;
|
||||
redoStack?: unknown;
|
||||
};
|
||||
if (!Array.isArray(undoStack) || !Array.isArray(redoStack)) return null;
|
||||
return {
|
||||
canUndo: undoStack.length > 0,
|
||||
canRedo: redoStack.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function historyAvailability(editor: Editor): {
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
@@ -43,16 +67,14 @@ function historyAvailability(editor: Editor): {
|
||||
|
||||
// Collaboration history (Yjs) takes precedence when present.
|
||||
const yState = yUndoPluginKey.getState(state) as
|
||||
| { undoManager?: { undoStack: unknown[]; redoStack: unknown[] } }
|
||||
| { undoManager?: unknown }
|
||||
| undefined;
|
||||
if (yState?.undoManager) {
|
||||
return {
|
||||
canUndo: yState.undoManager.undoStack.length > 0,
|
||||
canRedo: yState.undoManager.redoStack.length > 0,
|
||||
};
|
||||
}
|
||||
const yAvail = yHistoryAvailability(yState?.undoManager);
|
||||
if (yAvail) return yAvail;
|
||||
|
||||
// Plain prosemirror-history (returns 0 when the history plugin is absent).
|
||||
// This is also the safe default when a Yjs UndoManager is present but its
|
||||
// private stack shape is no longer recognized (yHistoryAvailability -> null).
|
||||
return {
|
||||
canUndo: undoDepth(state) > 0,
|
||||
canRedo: redoDepth(state) > 0,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Italic } from "@tiptap/extension-italic";
|
||||
import { Link } from "@tiptap/extension-link";
|
||||
import { gitmostInsertTranscriptIntoEditor } from "./gitmost-recording.ts";
|
||||
|
||||
const ZWSP = ""; // U+200B, the helper's block-trigger neutralizer
|
||||
const ZWSP = ""; // U+200B — asserted ABSENT (the block-escape lives in the serializer now)
|
||||
|
||||
/**
|
||||
* #377 — the web-side bridge must append the native host's transcript below the
|
||||
@@ -18,8 +18,9 @@ const ZWSP = ""; // U+200B, the helper's block-trigger neutralizer
|
||||
* regression would be caught), asserting the resulting document rather than
|
||||
* mocking the editor: transcript present -> "Transcript" heading + one paragraph
|
||||
* per non-empty line; content is inserted as LITERAL TEXT (no HTML/markdown
|
||||
* parsing); col-0 markdown block triggers are neutralized so git-sync keeps them
|
||||
* paragraphs; absent/empty/non-string -> no-op.
|
||||
* parsing); col-0 markdown block triggers are stored verbatim (the git-sync
|
||||
* serializer block-escapes them, so no client-side ZWSP is needed);
|
||||
* absent/empty/non-string -> no-op.
|
||||
*/
|
||||
describe("gitmostInsertTranscriptIntoEditor", () => {
|
||||
const makeEditor = () =>
|
||||
@@ -91,19 +92,22 @@ describe("gitmostInsertTranscriptIntoEditor", () => {
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("neutralizes col-0 markdown block triggers with a leading ZWSP (git-sync safety)", () => {
|
||||
it("inserts col-0 markdown block triggers as verbatim paragraph text (no ZWSP workaround)", () => {
|
||||
const editor = makeEditor();
|
||||
// Trigger lines (some with a leaked indent) + a normal prefixed line.
|
||||
// Trigger lines (some with a leaked indent) + a normal prefixed line. The
|
||||
// git-sync serializer now block-escapes a leading trigger itself, so the
|
||||
// bridge inserts each line's TEXT byte-exact (only the leaked indent is
|
||||
// trimmed) — no invisible ZWSP is prepended anymore.
|
||||
const inserted = gitmostInsertTranscriptIntoEditor(
|
||||
editor,
|
||||
[
|
||||
"- dash",
|
||||
" > quote", // leading indent must be trimmed then neutralized
|
||||
" > quote", // leading indent is trimmed, text otherwise verbatim
|
||||
"# hash",
|
||||
"1. one",
|
||||
"> [!info] note",
|
||||
"```js",
|
||||
"---", // solid thematic break -> horizontalRule (text-losing) if unneutralized
|
||||
"---",
|
||||
"***",
|
||||
"___",
|
||||
"You: normal line",
|
||||
@@ -116,20 +120,23 @@ describe("gitmostInsertTranscriptIntoEditor", () => {
|
||||
.map((n: any) => n.content?.[0]?.text)
|
||||
.filter((t: any) => typeof t === "string") as string[];
|
||||
|
||||
// Every block-trigger line is prefixed with the invisible ZWSP (indent
|
||||
// trimmed first); the normal `You:` line is left byte-exact.
|
||||
// Each trigger line is stored as its own byte-exact text (indent trimmed);
|
||||
// the git-sync round-trip keeps it a paragraph via the serializer's
|
||||
// block-escape, so no ZWSP is needed here.
|
||||
expect(texts).toEqual([
|
||||
ZWSP + "- dash",
|
||||
ZWSP + "> quote",
|
||||
ZWSP + "# hash",
|
||||
ZWSP + "1. one",
|
||||
ZWSP + "> [!info] note",
|
||||
ZWSP + "```js",
|
||||
ZWSP + "---",
|
||||
ZWSP + "***",
|
||||
ZWSP + "___",
|
||||
"- dash",
|
||||
"> quote",
|
||||
"# hash",
|
||||
"1. one",
|
||||
"> [!info] note",
|
||||
"```js",
|
||||
"---",
|
||||
"***",
|
||||
"___",
|
||||
"You: normal line",
|
||||
]);
|
||||
// Guard: no invisible ZWSP leaked into any inserted line.
|
||||
for (const t of texts) expect(t).not.toContain(ZWSP);
|
||||
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
@@ -240,45 +240,22 @@ export async function gitmostUploadFileToEditor(
|
||||
}
|
||||
}
|
||||
|
||||
// Zero-width space (U+200B). Prepended to a transcript line that begins with a
|
||||
// markdown BLOCK trigger: it is invisible in the rendered doc but shifts the
|
||||
// trigger off column 0, so the git-sync doc->markdown->doc round-trip keeps the
|
||||
// line a plain paragraph (see GITMOST_MD_BLOCK_TRIGGER_RE).
|
||||
const GITMOST_ZWSP = "";
|
||||
|
||||
// A markdown BLOCK-level construct that, sitting at column 0 of a paragraph
|
||||
// line, the git-sync markdown serializer (packages/prosemirror-markdown
|
||||
// markdown-converter.ts, `case "paragraph"`) would re-parse into a NON-paragraph
|
||||
// block on the doc->markdown->doc cycle. That serializer emits paragraph text
|
||||
// verbatim with NO block-escape (the pre-existing root cause), so a leading
|
||||
// `#`/`-`/`*`/`+`/`>`, an ordered-list `N.`/`N)`, a code fence ```/~~~, a table
|
||||
// `|`, or a `> [!info]` callout opener would silently become a heading / list /
|
||||
// quote / code block / table / callout. The final alternative matches a WHOLE-
|
||||
// LINE thematic break — solid `---`/`***`/`___` or spaced `- - -`/`_ _ _` (3+ of
|
||||
// the same `-`/`*`/`_`) — which round-trips into a `horizontalRule`; because
|
||||
// that node carries NO text, an un-neutralized separator line would LOSE its
|
||||
// text entirely (worse than the list/quote case). This matches a TRIMMED line's
|
||||
// start; the transcript's own `You:` / `Speaker N:` prefix begins with a letter
|
||||
// and never matches, so prefixed lines are left byte-exact.
|
||||
const GITMOST_MD_BLOCK_TRIGGER_RE =
|
||||
/^(?:#{1,6}(?:\s|$)|[-*+](?:\s|$)|>|\d+[.)](?:\s|$)|```|~~~|\||([-*_])(?:\s*\1){2,}\s*$)/;
|
||||
|
||||
// Append a transcript block BELOW the recording's audio node in a live editor:
|
||||
// a "Transcript" heading followed by one paragraph per non-empty transcript
|
||||
// line. The transcript is plain text, `\n`-separated, each line already
|
||||
// formatted as `You: ...` / `Speaker N: ...` by the native host — line text is
|
||||
// inserted as a TEXT node (never HTML/markdown), so there is no injection or
|
||||
// mark-parsing surface. Each kept line is trimmed (drops an indent that would
|
||||
// both leak into the display and, at col 0, form a markdown block trigger) and,
|
||||
// if it still begins with a col-0 markdown block trigger, gets an invisible
|
||||
// zero-width space prepended so the git-sync round-trip cannot turn it into a
|
||||
// list/quote/heading/callout/code/table (defensive boundary against the
|
||||
// serializer's missing block-escape). This is best-effort and meant to run
|
||||
// AFTER the audio has already been inserted; the caller must guard against a
|
||||
// throw so a transcript failure never fails the (already successful) recording.
|
||||
// Returns true when a block was inserted, false when there was nothing to
|
||||
// insert (transcript undefined/empty/not-a-string). A non-string value is a
|
||||
// no-op, not an error.
|
||||
// leak into the display). A line that begins with a col-0 markdown block
|
||||
// trigger (`#`/`-`/`>`/`1.`/fence/`---`/…) needs no client-side workaround: the
|
||||
// git-sync serializer (packages/prosemirror-markdown, `case "paragraph"`) now
|
||||
// block-escapes such a leading trigger, so the doc->markdown->doc round-trip
|
||||
// keeps the line a paragraph on its own — the former invisible-ZWSP defense is
|
||||
// gone. This is best-effort and meant to run AFTER the audio has already been
|
||||
// inserted; the caller must guard against a throw so a transcript failure never
|
||||
// fails the (already successful) recording. Returns true when a block was
|
||||
// inserted, false when there was nothing to insert (transcript
|
||||
// undefined/empty/not-a-string). A non-string value is a no-op, not an error.
|
||||
export function gitmostInsertTranscriptIntoEditor(
|
||||
editor: Editor,
|
||||
transcript: unknown,
|
||||
@@ -288,13 +265,7 @@ export function gitmostInsertTranscriptIntoEditor(
|
||||
.split("\n")
|
||||
// Trim each line and drop blank (whitespace-only) ones.
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
// Neutralize a col-0 markdown block trigger with an invisible ZWSP so the
|
||||
// git-sync round-trip keeps the line a paragraph. Host lines (`You:` /
|
||||
// `Speaker N:`) never match and stay byte-exact.
|
||||
.map((line) =>
|
||||
GITMOST_MD_BLOCK_TRIGGER_RE.test(line) ? GITMOST_ZWSP + line : line,
|
||||
);
|
||||
.filter((line) => line.length > 0);
|
||||
if (lines.length === 0) return false;
|
||||
|
||||
const content = [
|
||||
|
||||
@@ -281,9 +281,11 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
|
||||
setOpenTreeNodes((prev) => ({ ...prev, [id]: isOpen }));
|
||||
if (isOpen) {
|
||||
const node = treeModel.find(data, id) as SpaceTreeNode | null;
|
||||
// Same "unloaded branch" predicate the insert paths use (`isUnloadedBranch`)
|
||||
// so the lazy-load gate and the realtime/DnD inserts can never disagree
|
||||
// about what counts as unloaded (#525).
|
||||
// Same "unloaded branch" predicate the realtime insert paths use
|
||||
// (`isUnloadedBranch`) so the lazy-load gate and the realtime inserts
|
||||
// (`insertByPosition` / `placeByPosition`) can never disagree about what
|
||||
// counts as unloaded (#525). Note: local raw `insert` (DnD/create-page)
|
||||
// does not yet route through it — see #525 follow-up.
|
||||
if (treeModel.isUnloadedBranch(node)) {
|
||||
const fetched = await fetchAllAncestorChildren({
|
||||
pageId: id,
|
||||
|
||||
@@ -49,9 +49,11 @@ export const treeModel = {
|
||||
// resetting collapsed branches), NOT `children: undefined` — so a predicate that
|
||||
// only checks `=== undefined` misses the real case and materializes a misleading
|
||||
// partial list (#525). This is the SINGLE source of truth for "should a
|
||||
// fetch/materialize be deferred?", shared by the lazy-load gate (`handleToggle`),
|
||||
// the realtime insert path (`insertByPosition`) and the DnD move guard, so they
|
||||
// can never drift apart again. A parent WITHOUT `hasChildren` is genuinely empty
|
||||
// fetch/materialize be deferred?", shared by the lazy-load gate (`handleToggle`)
|
||||
// and the realtime insert paths (`insertByPosition` / `placeByPosition`), so they
|
||||
// can never drift apart again. (The local raw `insert` primitive and its DnD/
|
||||
// create-page callers do NOT yet route through this predicate — see #525
|
||||
// follow-up.) A parent WITHOUT `hasChildren` is genuinely empty
|
||||
// (no server children) — inserting its first child is correct, not deferred.
|
||||
isUnloadedBranch<T extends object>(
|
||||
node: TreeNode<T> | null | undefined,
|
||||
|
||||
@@ -13,8 +13,7 @@ let currentAlias: IShareAlias | null = null;
|
||||
let availabilityResult: {
|
||||
valid: boolean;
|
||||
available: boolean;
|
||||
currentPageId: string | null;
|
||||
} = { valid: true, available: true, currentPageId: null };
|
||||
} = { valid: true, available: true };
|
||||
|
||||
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||
useShareAliasForPageQuery: () => ({ data: currentAlias }),
|
||||
@@ -56,7 +55,7 @@ describe("ShareAliasSection — taken-name handling is never a dead end", () =>
|
||||
beforeEach(() => {
|
||||
setMutateAsync.mockReset();
|
||||
currentAlias = null;
|
||||
availabilityResult = { valid: true, available: true, currentPageId: null };
|
||||
availabilityResult = { valid: true, available: true };
|
||||
});
|
||||
|
||||
it("shows a 'will move it here' HINT (not a terminal error) when the name belongs to another page, and keeps Save enabled", async () => {
|
||||
@@ -65,7 +64,6 @@ describe("ShareAliasSection — taken-name handling is never a dead end", () =>
|
||||
availabilityResult = {
|
||||
valid: true,
|
||||
available: false,
|
||||
currentPageId: "page-X",
|
||||
};
|
||||
|
||||
renderSection("page-Y");
|
||||
@@ -97,7 +95,6 @@ describe("ShareAliasSection — taken-name handling is never a dead end", () =>
|
||||
availabilityResult = {
|
||||
valid: true,
|
||||
available: false,
|
||||
currentPageId: "page-X",
|
||||
};
|
||||
// The server rejects the un-confirmed save asking the client to confirm.
|
||||
setMutateAsync.mockRejectedValueOnce({
|
||||
@@ -106,7 +103,6 @@ describe("ShareAliasSection — taken-name handling is never a dead end", () =>
|
||||
status: 409,
|
||||
data: {
|
||||
code: "ALIAS_REASSIGN_REQUIRED",
|
||||
currentPageId: "page-X",
|
||||
currentPageTitle: "Alias Test Page X",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -48,7 +48,6 @@ export default function ShareAliasSection({
|
||||
const [availability, setAvailability] = useState<{
|
||||
valid: boolean;
|
||||
available: boolean;
|
||||
currentPageId: string | null;
|
||||
} | null>(null);
|
||||
const [reassign, setReassign] = useState<{
|
||||
alias: string;
|
||||
@@ -76,7 +75,6 @@ export default function ShareAliasSection({
|
||||
setAvailability({
|
||||
valid: res.valid,
|
||||
available: res.available,
|
||||
currentPageId: res.currentPageId,
|
||||
});
|
||||
} catch {
|
||||
setAvailability(null);
|
||||
|
||||
@@ -108,7 +108,6 @@ export interface IShareAliasAvailability {
|
||||
alias: string;
|
||||
valid: boolean;
|
||||
available: boolean;
|
||||
currentPageId: string | null;
|
||||
}
|
||||
|
||||
export interface ISharedPageTree {
|
||||
|
||||
+10
-2
@@ -28,6 +28,7 @@ import {
|
||||
IAiMcpServerCreate,
|
||||
IAiMcpServerUpdate,
|
||||
} from "@/features/workspace/services/ai-mcp-server-service.ts";
|
||||
import { resolveToolAllowlist } from "./ai-mcp-server-form.utils.ts";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
@@ -121,13 +122,20 @@ export default function AiMcpServerForm({
|
||||
async function handleSubmit(values: FormValues) {
|
||||
const headers = resolveHeaders();
|
||||
|
||||
// An empty tag field means "no restriction" (sent as null) — since #476 the
|
||||
// server persists a literal `[]` as deny-all (zero tools). But a server that
|
||||
// was ALREADY deny-all loads into an empty field too; sending null there
|
||||
// would silently widen it to allow-all on a routine edit, so preserve `[]`.
|
||||
// See resolveToolAllowlist for the full rationale.
|
||||
const toolAllowlist = resolveToolAllowlist(values.toolAllowlist, server);
|
||||
|
||||
if (isEdit && server) {
|
||||
const payload: IAiMcpServerUpdate = {
|
||||
id: server.id,
|
||||
name: values.name,
|
||||
transport: values.transport,
|
||||
url: values.url,
|
||||
toolAllowlist: values.toolAllowlist,
|
||||
toolAllowlist,
|
||||
// Always sent: a blank value clears the stored guidance (server -> null).
|
||||
instructions: values.instructions,
|
||||
enabled: values.enabled,
|
||||
@@ -140,7 +148,7 @@ export default function AiMcpServerForm({
|
||||
name: values.name,
|
||||
transport: values.transport,
|
||||
url: values.url,
|
||||
toolAllowlist: values.toolAllowlist,
|
||||
toolAllowlist,
|
||||
// Blank => server stores null (no guidance).
|
||||
instructions: values.instructions,
|
||||
enabled: values.enabled,
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveToolAllowlist } from "./ai-mcp-server-form.utils.ts";
|
||||
|
||||
describe("resolveToolAllowlist", () => {
|
||||
it("sends the typed tools when the field is non-empty", () => {
|
||||
expect(resolveToolAllowlist(["a", "b"], { toolAllowlist: null })).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates as null (unrestricted) when empty and there is no server", () => {
|
||||
expect(resolveToolAllowlist([], undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("sends null for an empty field on a previously-unrestricted server", () => {
|
||||
expect(resolveToolAllowlist([], { toolAllowlist: null })).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves deny-all: an empty field on a `[]` server stays `[]`, not null", () => {
|
||||
// The core #476/#477 guard: editing a deny-all server (rename/toggle) with
|
||||
// an empty tag field must NOT silently widen it to allow-all.
|
||||
expect(resolveToolAllowlist([], { toolAllowlist: [] })).toEqual([]);
|
||||
});
|
||||
|
||||
it("still sends explicit tools even if the server was deny-all", () => {
|
||||
expect(resolveToolAllowlist(["x"], { toolAllowlist: [] })).toEqual(["x"]);
|
||||
});
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { IAiMcpServer } from "@/features/workspace/services/ai-mcp-server-service.ts";
|
||||
|
||||
// Resolve the tool allowlist value to persist from the form field.
|
||||
//
|
||||
// An empty tag field normally means "no restriction" and is sent as null so
|
||||
// the server drops the column (all tools allowed). But a server that was
|
||||
// ALREADY deny-all (a stored literal `[]`, meaning zero tools — creatable via
|
||||
// the API) loads into the form as an empty field too. Coercing that empty
|
||||
// field to null on submit would SILENTLY widen a deny-all server to allow-all
|
||||
// on any routine edit (rename, toggle) — the exact silent-widen class #476
|
||||
// closed on the read side. So when the edited server was deny-all, preserve
|
||||
// `[]` (deny-all); only a genuinely-unrestricted server (stored null/absent)
|
||||
// stays null.
|
||||
export function resolveToolAllowlist(
|
||||
fieldValue: string[],
|
||||
server?: Pick<IAiMcpServer, "toolAllowlist">,
|
||||
): string[] | null {
|
||||
if (fieldValue.length > 0) return fieldValue;
|
||||
const wasDenyAll =
|
||||
Array.isArray(server?.toolAllowlist) && server.toolAllowlist.length === 0;
|
||||
return wasDenyAll ? [] : null;
|
||||
}
|
||||
+124
@@ -6,6 +6,8 @@ import {
|
||||
nextReindexPollInterval,
|
||||
isReindexComplete,
|
||||
isReindexButtonLoading,
|
||||
reindexRunKey,
|
||||
isNewReindexRun,
|
||||
} from './ai-provider-settings';
|
||||
|
||||
describe('resolveCardStatus', () => {
|
||||
@@ -221,6 +223,128 @@ describe('isReindexComplete', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('reindexRunKey', () => {
|
||||
it('is null when the status carries no run identity', () => {
|
||||
expect(reindexRunKey(undefined)).toBeNull();
|
||||
expect(
|
||||
reindexRunKey({ reindexing: false, indexedPages: 5, totalPages: 5 }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('is null for a legacy/degraded record with an empty runId', () => {
|
||||
// The server sends runId='' for a record written before the field existed;
|
||||
// the client must treat that as "no identity" (fall back to prior behaviour).
|
||||
expect(
|
||||
reindexRunKey({
|
||||
reindexing: true,
|
||||
indexedPages: 0,
|
||||
totalPages: 10,
|
||||
runId: '',
|
||||
reindexStartedAt: 1000,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('folds runId and startedAt into one stable key', () => {
|
||||
expect(
|
||||
reindexRunKey({
|
||||
reindexing: true,
|
||||
indexedPages: 0,
|
||||
totalPages: 10,
|
||||
runId: 'run-a',
|
||||
reindexStartedAt: 1000,
|
||||
}),
|
||||
).toBe('run-a:1000');
|
||||
});
|
||||
|
||||
it('changes when the runId changes for the same startedAt', () => {
|
||||
const a = reindexRunKey({
|
||||
reindexing: true,
|
||||
indexedPages: 0,
|
||||
totalPages: 10,
|
||||
runId: 'run-a',
|
||||
reindexStartedAt: 1000,
|
||||
});
|
||||
const b = reindexRunKey({
|
||||
reindexing: true,
|
||||
indexedPages: 0,
|
||||
totalPages: 10,
|
||||
runId: 'run-b',
|
||||
reindexStartedAt: 1000,
|
||||
});
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('changes when the same runId restarts at a new startedAt', () => {
|
||||
const a = reindexRunKey({
|
||||
reindexing: true,
|
||||
indexedPages: 0,
|
||||
totalPages: 10,
|
||||
runId: 'run-a',
|
||||
reindexStartedAt: 1000,
|
||||
});
|
||||
const b = reindexRunKey({
|
||||
reindexing: true,
|
||||
indexedPages: 0,
|
||||
totalPages: 10,
|
||||
runId: 'run-a',
|
||||
reindexStartedAt: 2000,
|
||||
});
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNewReindexRun (poll keying on runId)', () => {
|
||||
// Derive the status shape from the helper itself so the test needs no export
|
||||
// of the component-internal ReindexStatus type.
|
||||
type ReindexStatusLike = NonNullable<Parameters<typeof reindexRunKey>[0]>;
|
||||
const run = (runId: string, startedAt: number): ReindexStatusLike => ({
|
||||
reindexing: true,
|
||||
indexedPages: 0,
|
||||
totalPages: 10,
|
||||
runId,
|
||||
reindexStartedAt: startedAt,
|
||||
});
|
||||
|
||||
it('first identity after none latched is a NEW run', () => {
|
||||
expect(isNewReindexRun(null, run('run-a', 1000))).toBe(true);
|
||||
});
|
||||
|
||||
it('the SAME identity is not a new run (same run being watched)', () => {
|
||||
const key = reindexRunKey(run('run-a', 1000));
|
||||
expect(isNewReindexRun(key, run('run-a', 1000))).toBe(false);
|
||||
});
|
||||
|
||||
it('a DIFFERENT runId is a new run (reset per-run poll state)', () => {
|
||||
const key = reindexRunKey(run('run-a', 1000));
|
||||
expect(isNewReindexRun(key, run('run-b', 1000))).toBe(true);
|
||||
});
|
||||
|
||||
it('an identity-less poll (no runId / cleared record) is never a new run', () => {
|
||||
const key = reindexRunKey(run('run-a', 1000));
|
||||
expect(
|
||||
isNewReindexRun(key, {
|
||||
reindexing: false,
|
||||
indexedPages: 10,
|
||||
totalPages: 10,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('a legacy empty-runId poll does not spuriously reset a latched run', () => {
|
||||
const key = reindexRunKey(run('run-a', 1000));
|
||||
expect(
|
||||
isNewReindexRun(key, {
|
||||
reindexing: true,
|
||||
indexedPages: 3,
|
||||
totalPages: 10,
|
||||
runId: '',
|
||||
reindexStartedAt: 1000,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isReindexButtonLoading', () => {
|
||||
it('loads while the POST mutation is pending', () => {
|
||||
expect(
|
||||
|
||||
+54
-1
@@ -173,9 +173,43 @@ export function resolveKeyField(
|
||||
// Subset of the status payload that drives the reindex poll decisions.
|
||||
type ReindexStatus = Pick<
|
||||
IAiSettings,
|
||||
"reindexing" | "indexedPages" | "totalPages"
|
||||
"reindexing" | "indexedPages" | "totalPages" | "runId" | "reindexStartedAt"
|
||||
>;
|
||||
|
||||
/**
|
||||
* A stable per-RUN key for the reindex poll: `runId:startedAt`, or `null` when
|
||||
* the status carries no run identity (no active run, or a legacy/degraded
|
||||
* server record with an empty runId). Two polls of the SAME run share a key; a
|
||||
* new run mints a fresh runId and so a different key.
|
||||
*
|
||||
* This is the single place the client turns the server's run identity into the
|
||||
* value it keys on — it removes the "is this the same run I've been watching or
|
||||
* a brand-new one?" ambiguity that made a class of reindex-status bugs (a stale
|
||||
* pre-reindex snapshot vs a fresh run) get fixed twice (#262). `startedAt` is
|
||||
* folded in so a run that somehow reuses a runId but restarted is still new.
|
||||
*/
|
||||
export function reindexRunKey(status: ReindexStatus | undefined): string | null {
|
||||
const runId = status?.runId;
|
||||
if (!runId) return null;
|
||||
return `${runId}:${status?.reindexStartedAt ?? ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the latest poll represents a NEW reindex run relative to the
|
||||
* run key the client last latched (`prevKey`, `null` if none yet). True only
|
||||
* when the status carries an identity AND it differs from the latched one — the
|
||||
* signal to reset any per-run poll state (the "seen active" latch / progress the
|
||||
* UI held). The same identity (or no identity) is NOT a new run, so an unchanged
|
||||
* or identity-less poll never resets mid-run.
|
||||
*/
|
||||
export function isNewReindexRun(
|
||||
prevKey: string | null,
|
||||
status: ReindexStatus | undefined,
|
||||
): boolean {
|
||||
const key = reindexRunKey(status);
|
||||
return key !== null && key !== prevKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the TanStack Query `refetchInterval` while a reindex may be running.
|
||||
* Returns the poll interval (ms) to keep polling, or `false` to stop.
|
||||
@@ -320,6 +354,13 @@ export default function AiProviderSettings() {
|
||||
// counter at 0 until a manual reload. A ref (not state) because it must not
|
||||
// trigger a render and is only ever read where `reindexing` is already false.
|
||||
const reindexSeenActiveRef = useRef(false);
|
||||
// The run identity (runId:startedAt) the current poll window is keyed on. When
|
||||
// a poll reports a DIFFERENT runId the server has started a NEW run, so we
|
||||
// re-latch to it and reset `reindexSeenActiveRef` — a fresh run must never
|
||||
// inherit the previous run's "seen active"/completion state (which would stop
|
||||
// polling immediately or read the old run's counters as this run's). null =
|
||||
// no run keyed yet (steady state, or a legacy record without a runId).
|
||||
const reindexRunKeyRef = useRef<string | null>(null);
|
||||
|
||||
// Only admins may read the (masked) AI settings; the server enforces this too.
|
||||
const { data: settings, isLoading } = useAiSettingsQuery(isAdmin, (query) =>
|
||||
@@ -336,6 +377,14 @@ export default function AiProviderSettings() {
|
||||
// unmount because the deadline state goes away with the component.
|
||||
useEffect(() => {
|
||||
if (reindexDeadline === null) return;
|
||||
// Key the poll on the run identity: if this poll carries a runId different
|
||||
// from the one we latched, the server started a NEW run, so adopt it and
|
||||
// drop the per-run "seen active" latch (a fresh run must not inherit the
|
||||
// previous run's completion state). Same runId => same run, leave it alone.
|
||||
if (isNewReindexRun(reindexRunKeyRef.current, settings)) {
|
||||
reindexRunKeyRef.current = reindexRunKey(settings);
|
||||
reindexSeenActiveRef.current = false;
|
||||
}
|
||||
// Latch "we have seen the active run" the moment a poll reports it, so the
|
||||
// completion check below (and the refetchInterval's) only fires once the run
|
||||
// has genuinely started — never on the stale pre-reindex snapshot.
|
||||
@@ -1220,6 +1269,10 @@ export default function AiProviderSettings() {
|
||||
// immediately.
|
||||
onSuccess: () => {
|
||||
reindexSeenActiveRef.current = false;
|
||||
// Forget the previous run's identity so the first poll of
|
||||
// this window (carrying the new run's runId) is recognized
|
||||
// as a new run and keyed afresh.
|
||||
reindexRunKeyRef.current = null;
|
||||
setReindexDeadline(Date.now() + REINDEX_POLL_CAP_MS);
|
||||
},
|
||||
})
|
||||
|
||||
@@ -27,7 +27,9 @@ export interface IAiMcpServerCreate {
|
||||
// Auth headers map (e.g. { Authorization: 'Bearer ...' }). Encrypted on save;
|
||||
// never returned.
|
||||
headers?: Record<string, string>;
|
||||
toolAllowlist?: string[];
|
||||
// Omit/null => no restriction; `[]` is persisted verbatim and means
|
||||
// deny-all (zero tools) since #476.
|
||||
toolAllowlist?: string[] | null;
|
||||
// Admin-authored prompt guidance (#180). Blank => stored as null.
|
||||
instructions?: string;
|
||||
enabled?: boolean;
|
||||
@@ -43,7 +45,9 @@ export interface IAiMcpServerUpdate {
|
||||
transport?: McpTransport;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
toolAllowlist?: string[];
|
||||
// Absent => unchanged; null => no restriction; `[]` is persisted verbatim
|
||||
// and means deny-all (zero tools) since #476.
|
||||
toolAllowlist?: string[] | null;
|
||||
// Admin-authored prompt guidance (#180). Absent => unchanged; blank => cleared.
|
||||
instructions?: string;
|
||||
enabled?: boolean;
|
||||
|
||||
@@ -51,6 +51,14 @@ export interface IAiSettings {
|
||||
// True while a full workspace reindex is actively running; the counts above
|
||||
// then reflect the live run progress (done climbs 0 -> total).
|
||||
reindexing?: boolean;
|
||||
// Identity of the ACTIVE reindex run (present only while `reindexing`). The
|
||||
// poll keys on `runId`: a changed value means a NEW run (reset the per-run
|
||||
// poll state the UI latched), the same value is the run already being watched.
|
||||
// Absent/empty ('') => no identity available; the client keeps prior behaviour.
|
||||
runId?: string;
|
||||
// Epoch-ms the active run started; paired with `runId` so a restart with a
|
||||
// recycled id is still detected as a new run.
|
||||
reindexStartedAt?: number;
|
||||
}
|
||||
|
||||
// Update payload. Key semantics (same for `apiKey` and `embeddingApiKey`):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { templateRoute } from "./route-template";
|
||||
import { templateRoute, KNOWN_ROUTE_TEMPLATES } from "./route-template";
|
||||
|
||||
describe("templateRoute", () => {
|
||||
it("templates a space page path (never leaks slugs)", () => {
|
||||
@@ -32,4 +32,30 @@ describe("templateRoute", () => {
|
||||
expect(templateRoute("/weird/unknown/thing")).toBe("other");
|
||||
expect(templateRoute("/s/team/p/slug/extra/segments")).toBe("other");
|
||||
});
|
||||
|
||||
// The server's /api/telemetry/vitals mirror (ALLOWED_ROUTE_TEMPLATES) drops any
|
||||
// route outside KNOWN_ROUTE_TEMPLATES, so templateRoute must NEVER emit a label
|
||||
// that is not in that dictionary — otherwise legit client metrics get dropped.
|
||||
it("only ever emits labels contained in KNOWN_ROUTE_TEMPLATES (#495)", () => {
|
||||
const samples = [
|
||||
"/",
|
||||
"/home",
|
||||
"/settings/members",
|
||||
"/settings/groups/g-1",
|
||||
"/s/team",
|
||||
"/s/team/trash",
|
||||
"/s/team/p/slug",
|
||||
"/p/slug",
|
||||
"/share/abc",
|
||||
"/share/abc/p/slug",
|
||||
"/share/p/slug",
|
||||
"/labels/urgent",
|
||||
"/invites/inv-1",
|
||||
"/weird/unknown/thing", // -> "other"
|
||||
"/deep/unmatched/x/y/z", // -> "other"
|
||||
];
|
||||
for (const path of samples) {
|
||||
expect(KNOWN_ROUTE_TEMPLATES.has(templateRoute(path))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,22 @@ const STATIC_ROUTES = new Set<string>([
|
||||
'/settings/sharing',
|
||||
]);
|
||||
|
||||
/**
|
||||
* The COMPLETE, finite vocabulary `templateRoute` can ever emit: the two
|
||||
* synthetic labels (`/` and `other`), the static routes, and the dynamic
|
||||
* templates. Exported so the public `/api/telemetry/vitals` endpoint can reject
|
||||
* any `route` outside this dictionary server-side (the endpoint is anonymous, so
|
||||
* an un-checked `route` is a free-text write surface). The server keeps a mirror
|
||||
* (`ALLOWED_ROUTE_TEMPLATES` in client-metrics.constants.ts) — this is the
|
||||
* canonical source; keep them in lockstep.
|
||||
*/
|
||||
export const KNOWN_ROUTE_TEMPLATES: ReadonlySet<string> = new Set<string>([
|
||||
'/',
|
||||
'other',
|
||||
...STATIC_ROUTES,
|
||||
...ROUTE_PATTERNS.map((p) => p.template),
|
||||
]);
|
||||
|
||||
export function templateRoute(pathname: string): string {
|
||||
// Normalise a trailing slash (except root).
|
||||
const path =
|
||||
|
||||
@@ -3,6 +3,7 @@ import "@mantine/spotlight/styles.css";
|
||||
import "@mantine/notifications/styles.css";
|
||||
import '@mantine/dates/styles.css';
|
||||
import "@/styles/a11y-overrides.css";
|
||||
import "@/styles/notification-overrides.css";
|
||||
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
@@ -47,7 +48,15 @@ function renderApp() {
|
||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||
<ModalsProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
||||
{/* top-center: toasts sit in the top of the viewport, in the line
|
||||
of sight, and no longer cover centered content (e.g. "Load
|
||||
more"). The below-chrome vertical offset is applied via a
|
||||
position-scoped CSS rule in notification-overrides.css (NOT an
|
||||
inline `style`): Mantine renders all six position containers at
|
||||
once and an inline root style would land on every one, giving the
|
||||
bottom-* containers both top+bottom → full-viewport transparent
|
||||
overlays that swallow clicks. */}
|
||||
<Notifications position="top-center" limit={3} zIndex={10000} />
|
||||
<HelmetProvider>
|
||||
{/* Root boundary above every lazy route's Suspense: a stale-chunk
|
||||
404 after a deploy is caught and recovered here instead of
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Toast (Mantine Notification) visibility overrides.
|
||||
* Mantine renders colorless toasts on --mantine-color-body (== the page
|
||||
* background: white in light mode) with a faint shadow, so on white pages the
|
||||
* card has no visible edge. These rules give every toast a type-tinted
|
||||
* background, a WCAG-checked border and a stronger shadow so it separates from
|
||||
* the page. The [data-mantine-color-scheme] + static-class selector (0,2,0)
|
||||
* beats Mantine's own (0,1,0) rules regardless of stylesheet order (Mantine's
|
||||
* bg/border rules wrap the scheme attribute in :where(), so they stay (0,1,0)).
|
||||
* --notification-color is defined on the same element (defaults to primary,
|
||||
* set per `color` prop), so tint/border follow the toast type. This also covers
|
||||
* the loading/import toast (no accent bar, since the spinner takes the icon
|
||||
* slot): its visibility comes from tone + border + shadow + the colored spinner.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Push the top-anchored toast containers below the top chrome (fixed 45px
|
||||
* header + optional 45px format toolbar + ~6px gap) so a toast (z-index 10000)
|
||||
* neither covers nor intercepts clicks on the header/toolbar (both z-index 99).
|
||||
*
|
||||
* Scoped to [data-position^='top'] on purpose. Mantine renders ALL SIX position
|
||||
* containers simultaneously (`position` only routes toasts into one via the
|
||||
* store); the root `style` prop would be applied to every one of them by
|
||||
* getStyles("root"). A blanket `top` would land on the bottom-* containers too
|
||||
* (which carry `bottom:16px`) → position:fixed + both edges + height:auto makes
|
||||
* them stretch the full viewport height, and the container root has neither
|
||||
* pointer-events:none nor a background, so those transparent z-10000 overlays
|
||||
* would swallow clicks across the whole page. Restricting to top-* leaves the
|
||||
* bottom containers at height:0.
|
||||
*
|
||||
* Specificity: `.mantine-Notifications-root[data-position^='top']` is (0,2,0)
|
||||
* (class + attribute) and beats Mantine's own top rule
|
||||
* `.m_b37d9ac7:where([data-position='top-center']){top:16px}` which is (0,1,0)
|
||||
* (the :where() contributes 0), regardless of stylesheet order.
|
||||
*/
|
||||
.mantine-Notifications-root[data-position^='top'] {
|
||||
top: 96px;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme='light'] .mantine-Notification-root {
|
||||
/* ~10% type color over white: clearly off-white, text contrast preserved */
|
||||
background-color: color-mix(in srgb, var(--notification-color) 10%, var(--mantine-color-white));
|
||||
/* Border must clear WCAG 3:1 non-text contrast on white. The repo rejects
|
||||
gray-4 for this (a11y-overrides.css); gray-6 base (~3.32:1) darkened by the
|
||||
type color stays >= 3:1. */
|
||||
border: 1px solid color-mix(in srgb, var(--notification-color) 45%, var(--mantine-color-gray-6));
|
||||
box-shadow: var(--mantine-shadow-xl);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme='dark'] .mantine-Notification-root {
|
||||
/* Dark page (dark-7/8) vs toast (dark-6) already separate a little; border +
|
||||
shadow carry the type cue here (a 7% dark tint was near-invisible). */
|
||||
background-color: color-mix(in srgb, var(--notification-color) 14%, var(--mantine-color-dark-6));
|
||||
border: 1px solid color-mix(in srgb, var(--notification-color) 45%, var(--mantine-color-dark-3));
|
||||
box-shadow: var(--mantine-shadow-xl);
|
||||
}
|
||||
|
||||
/* Mantine's message-with-title color is gray-6 (#868e96, already only ~3.32:1
|
||||
on white — below AA 4.5:1); the new tint pushes it lower. Bump to gray-7 to
|
||||
keep multi-line colored toasts readable, consistent with the repo's existing
|
||||
WCAG tuning (theme.ts already bumps this same gray-6 up elsewhere). */
|
||||
[data-mantine-color-scheme='light'] .mantine-Notification-description[data-with-title] {
|
||||
color: var(--mantine-color-gray-7);
|
||||
}
|
||||
Reference in New Issue
Block a user