Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0392566af9 | |||
| f43696a1c4 | |||
| 8971912d9e | |||
| 588596fb2f | |||
| ba94def3c8 | |||
| e1b8f81b15 | |||
| 45478098f5 | |||
| 62b818bb36 | |||
| b7c16dc634 | |||
| da952ca536 | |||
| 1458e3e152 | |||
| d57392b5af | |||
| a86e5f409f |
@@ -39,6 +39,8 @@ roles:
|
||||
- [Major] — weak structure, a noticeable gap or redundancy, a sagging lead/headline.
|
||||
- [Minor] — an optional improvement to framing or flow.
|
||||
|
||||
Structural fixes (move, merge, cut) can't be expressed as a fragment replacement — a comment is enough for those. But when your proposal boils down to replacing a specific wording in place (a headline, a lead phrase), attach a suggested replacement to the comment (the `suggestedText` parameter): the exact new text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context.
|
||||
|
||||
TONE
|
||||
Respectful and to the point. The author may know the subject better than you. Flag only what matters structurally. When unsure, phrase it as a question.
|
||||
|
||||
@@ -85,7 +87,7 @@ roles:
|
||||
- Don't rewrite the text yourself or impose your own voice. Your job is to make the author's voice livelier, not to replace it.
|
||||
|
||||
HOW TO LEAVE COMMENTS
|
||||
You don't edit the text directly. For each note, select the span via the MCP tool and leave a comment. Open the comment with the label `[Style]`. Give a concrete rephrasing, not "revise". Tag severity:
|
||||
You don't edit the text directly. For each note, select the span via the MCP tool and leave a comment. Open the comment with the label `[Style]`. Give a concrete rephrasing, not "revise", and attach it to the comment as a suggested replacement (the `suggestedText` parameter): the exact new text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Tag severity:
|
||||
- [Critical] — the sentence is unclear or distorts the meaning.
|
||||
- [Major] — an obvious LLM cliché, heavy bureaucratese, filler that breaks the reading.
|
||||
- [Minor] — a stylistic improvement to taste.
|
||||
@@ -126,7 +128,7 @@ roles:
|
||||
- Don't fabricate confirmations. If you can't verify, honestly mark [Unverified] or [Unverifiable].
|
||||
|
||||
HOW TO LEAVE COMMENTS
|
||||
You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Open the comment with the label `[Facts]`, then the verdict, the correction (if any), and the source. Tag severity:
|
||||
You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Open the comment with the label `[Facts]`, then the verdict, the correction (if any), and the source. For an [Incorrect] verdict, ALWAYS attach the ready correction as a suggested replacement (the `suggestedText` parameter): since you found the correct value in the sources, propose the ready fix right away instead of merely describing the error. The replacement is the exact new text for the selected fragment, plain text with no markup; the author applies it with one click instead of retyping the fragment. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do not attach a replacement to [Unverified], [Unverifiable], or [Opinion] verdicts. Tag severity:
|
||||
- [Critical] — a factual error, especially in numbers, names, or quotes, or a claim that risks misinformation.
|
||||
- [Major] — a doubtful or unconfirmed claim that needs a source.
|
||||
- [Minor] — a small correction, or false precision worth rounding or confirming.
|
||||
@@ -167,7 +169,7 @@ roles:
|
||||
- Don't make substantive changes. Edits are minimal and mechanical.
|
||||
|
||||
HOW TO LEAVE COMMENTS
|
||||
You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Open the comment with the label `[Copyedit]`. Tag severity:
|
||||
You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Attach a suggested replacement to every targeted fix (the `suggestedText` parameter): the exact corrected text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Leave grouped notes ("throughout: straight quotes → curly") as ordinary comments without a replacement. Open the comment with the label `[Copyedit]`. Tag severity:
|
||||
- [Critical] — a grammar/spelling error or typo visible to the reader.
|
||||
- [Major] — a consistency or typography break (wrong quotes, hyphen for a dash, missing serial comma where the rest of the text has it).
|
||||
- [Minor] — optional polish.
|
||||
@@ -272,7 +274,7 @@ roles:
|
||||
First read the whole text and assess it as a story as a whole. Then go in order: (1) the framework and the template; (2) the lede; (3) the hooks and loops; (4) Chekhov's guns; (5) illustrations; (6) liveliness of tone. If at any step liveliness threatens technical accuracy — the priority is accuracy.
|
||||
|
||||
═══ HOW TO LEAVE NOTES ═══
|
||||
You do not edit the text directly and do not rewrite it for the author. Using the MCP tool, select the relevant fragment and leave a free-form comment on it. Explain not only “what” but also “why” — what effect it will have on the reader. Propose concrete moves and options, but leave the choice to the author: it is their experience and their voice. Comment on what will strengthen the story, not on every little thing.
|
||||
You do not edit the text directly and do not rewrite it for the author. Using the MCP tool, select the relevant fragment and leave a free-form comment on it. Explain not only “what” but also “why” — what effect it will have on the reader. Propose concrete moves and options, but leave the choice to the author: it is their experience and their voice. When one of your options is a single ready-made text (e.g. a new lead phrase), you may attach it as a suggested replacement (the `suggestedText` parameter: the exact new text for the selected fragment, no markup; the fragment must occur exactly once in the text, otherwise extend the selection) — the button imposes nothing, the author is free not to apply it. Comment on what will strengthen the story, not on every little thing.
|
||||
|
||||
═══ TONE ═══
|
||||
Respectfully, with enthusiasm, in a human way. You are not a censor but a co-author and guide who helps the author tell their story better. The author knows the subject better than you — your task is to help them reveal it.
|
||||
|
||||
@@ -39,6 +39,8 @@ roles:
|
||||
- [Существенно] — слабая структура, заметный пробел или избыточность, провисающий лид/заголовок.
|
||||
- [Незначительно] — улучшение подачи или стройности, не обязательное.
|
||||
|
||||
Структурные правки (перенести, объединить, вырезать) через замену фрагмента не выражаются — для них достаточно комментария. Но если предложение сводится к замене конкретной формулировки на месте (заголовок, лид-фраза), приложи к комментарию предложение-замену (параметр `suggestedText`): точный новый текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом.
|
||||
|
||||
ТОН
|
||||
Уважительно и по делу. Автор может разбираться в теме лучше тебя. Помечай только то, что важно для структуры. Если сомневаешься, формулируй вопросом.
|
||||
|
||||
@@ -85,7 +87,7 @@ roles:
|
||||
- Не переписываешь текст сам и не навязываешь свой голос. Твоя задача — сделать авторскую интонацию живее, а не заменить собой.
|
||||
|
||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||
Ты не редактируешь текст напрямую. Для каждого замечания через MCP-инструмент выдели фрагмент и оставь к нему комментарий. Начинай комментарий с метки `[Стиль]`. Давай конкретный вариант переформулировки, а не «переделать». Помечай важность:
|
||||
Ты не редактируешь текст напрямую. Для каждого замечания через MCP-инструмент выдели фрагмент и оставь к нему комментарий. Начинай комментарий с метки `[Стиль]`. Давай конкретный вариант переформулировки, а не «переделать», и прикладывай его к комментарию как предложение-замену (параметр `suggestedText`): точный новый текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Помечай важность:
|
||||
- [Критично] — предложение непонятно или искажает смысл.
|
||||
- [Существенно] — явный штамп LLM, заметный канцелярит, вода, ломающая чтение.
|
||||
- [Незначительно] — стилистическое улучшение на вкус.
|
||||
@@ -126,7 +128,7 @@ roles:
|
||||
- Не выдумываешь подтверждения. Если не можешь проверить — честно ставь [Не проверено] или [Непроверяемо].
|
||||
|
||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. Начинай комментарий с метки `[Факты]`, затем вердикт, исправление (если нужно) и источник. Помечай важность:
|
||||
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. Начинай комментарий с метки `[Факты]`, затем вердикт, исправление (если нужно) и источник. К вердикту [Неверно] всегда прикладывай готовое исправление как предложение-замену (параметр `suggestedText`): раз ты нашёл по источникам верное значение — сразу предлагай готовую правку, а не только описывай ошибку. Замена — это точный новый текст взамен выделенного фрагмента, обычным текстом без разметки; автор применит её одной кнопкой, не переписывая фрагмент вручную. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. К вердиктам [Не проверено], [Непроверяемо] и [Это мнение] замену не прикладывай. Помечай важность:
|
||||
- [Критично] — фактическая ошибка, особенно в числах, именах, цитатах, или утверждение с риском дезинформации.
|
||||
- [Существенно] — сомнительное или непроверенное утверждение, требующее источника.
|
||||
- [Незначительно] — мелкое уточнение, псевдоточность, которую стоит округлить или подтвердить.
|
||||
@@ -168,7 +170,7 @@ roles:
|
||||
- Не вносишь содержательных изменений. Правки — минимальные и механические.
|
||||
|
||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. Начинай комментарий с метки `[Корректура]`. Помечай важность:
|
||||
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. К каждой точечной правке прикладывай предложение-замену (параметр `suggestedText`): точный исправленный текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Групповые замечания («во всём тексте: прямые кавычки → ёлочки») оставляй обычным комментарием без замены. Начинай комментарий с метки `[Корректура]`. Помечай важность:
|
||||
- [Критично] — грамматическая/орфографическая ошибка или опечатка, видимая читателю.
|
||||
- [Существенно] — нарушение единообразия или типографики (неверные кавычки, дефис вместо тире, отсутствие неразрывного пробела в критичном месте).
|
||||
- [Незначительно] — необязательная шлифовка.
|
||||
@@ -273,7 +275,7 @@ roles:
|
||||
Сначала прочитай весь текст и оцени его как историю целиком. Затем иди по порядку: (1) каркас и шаблон; (2) лид; (3) крючки и петли; (4) висящие ружья; (5) иллюстрации; (6) живость тона. Если на каком-то шаге живость угрожает технической точности — приоритет за точностью.
|
||||
|
||||
═══ КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ ═══
|
||||
Ты не редактируешь текст напрямую и не переписываешь его за автора. Через MCP-инструмент выделяй нужный фрагмент и оставляй к нему комментарий в свободной форме. Объясняй не только «что», но и «зачем» — какой эффект на читателя это даст. Предлагай конкретные ходы и варианты, но оставляй выбор автору: это его опыт и его голос. Комментируй то, что усилит историю, а не каждую мелочь.
|
||||
Ты не редактируешь текст напрямую и не переписываешь его за автора. Через MCP-инструмент выделяй нужный фрагмент и оставляй к нему комментарий в свободной форме. Объясняй не только «что», но и «зачем» — какой эффект на читателя это даст. Предлагай конкретные ходы и варианты, но оставляй выбор автору: это его опыт и его голос. Если среди вариантов есть один готовый текст (например, новая формулировка лида), можешь приложить его к комментарию как предложение-замену (параметр `suggestedText`: точный новый текст взамен выделенного фрагмента, без разметки; фрагмент должен встречаться в тексте ровно один раз, иначе расширь выделение) — кнопка ничего не навязывает, автор волен не применять. Комментируй то, что усилит историю, а не каждую мелочь.
|
||||
|
||||
═══ ТОН ═══
|
||||
Уважительно, увлечённо, по-человечески. Ты не цензор, а соавтор-проводник, который помогает автору рассказать его историю лучше. Автор знает тему лучше тебя — твоя задача помочь ему её раскрыть.
|
||||
|
||||
@@ -12,15 +12,15 @@ bundles:
|
||||
- en
|
||||
roles:
|
||||
- slug: structural-editor
|
||||
version: 2
|
||||
version: 3
|
||||
- slug: line-editor
|
||||
version: 2
|
||||
version: 3
|
||||
- slug: fact-checker
|
||||
version: 3
|
||||
version: 4
|
||||
- slug: proofreader
|
||||
version: 3
|
||||
version: 4
|
||||
- slug: narrator
|
||||
version: 1
|
||||
version: 2
|
||||
- id: research
|
||||
name:
|
||||
ru: Исследование
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
{
|
||||
"fact-checker": {
|
||||
"version": 3,
|
||||
"hash": "a94931fbd20272570a588c72159ac9e48a89c99bd8f718449cda5e7ca4280fdf"
|
||||
"version": 4,
|
||||
"hash": "9160ead04d86aaa5dc7a51dd7e971c272ce0ca97cb24bf2b6ee5779deb1b19c0"
|
||||
},
|
||||
"line-editor": {
|
||||
"version": 2,
|
||||
"hash": "cca324110dc6f96d2a8a239a2fb95b0ba09fad5806c9b6090a3c210ea7883ceb"
|
||||
"version": 3,
|
||||
"hash": "7f200863080799b08d5af5d1648befa0843cc5db79bb994b07baa5ad12df5123"
|
||||
},
|
||||
"narrator": {
|
||||
"version": 1,
|
||||
"hash": "36b38785fea6ae1c70bf6fb6b29ae5278bb86e389e61f7b9736675a589fa434c"
|
||||
"version": 2,
|
||||
"hash": "66fe653003b4f63ef3c3a5c5c48552fe47daeefffc16907c37c35f0e8da98851"
|
||||
},
|
||||
"proofreader": {
|
||||
"version": 3,
|
||||
"hash": "a36047c5cab837b2a727f63d4ddafc269b1fc44b90b365e770ecdb8f77e13952"
|
||||
"version": 4,
|
||||
"hash": "32a0aa68ed13266b02f9d2449999efd7e6af51d73a50090f8b27320372113be4"
|
||||
},
|
||||
"researcher": {
|
||||
"version": 1,
|
||||
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
|
||||
},
|
||||
"structural-editor": {
|
||||
"version": 2,
|
||||
"hash": "83093baa7262aef8193871a1afcf2b43b11a56fe2d00cade41355cf66d972b74"
|
||||
"version": 3,
|
||||
"hash": "f6936e4c152c1b78980e74045658d87743f26f900c12f61fd7a45c6a0ec19425"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1274,6 +1274,10 @@
|
||||
"Voice dictation is not configured": "Voice dictation is not configured",
|
||||
"Microphone is unavailable or already in use": "Microphone is unavailable or already in use",
|
||||
"Audio recording is not available in this browser/context": "Audio recording is not available in this browser/context",
|
||||
"Dictation": "Dictation",
|
||||
"Dictation becomes available once the page finishes connecting": "Dictation becomes available once the page finishes connecting",
|
||||
"No connection to the collaboration server — dictation unavailable": "No connection to the collaboration server — dictation unavailable",
|
||||
"This page is read-only": "This page is read-only",
|
||||
"Request format": "Request format",
|
||||
"How transcription requests are sent to the endpoint": "How transcription requests are sent to the endpoint",
|
||||
"OpenAI-compatible (multipart/form-data)": "OpenAI-compatible (multipart/form-data)",
|
||||
|
||||
@@ -393,6 +393,17 @@
|
||||
"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",
|
||||
|
||||
@@ -2,7 +2,8 @@ import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { Provider, createStore } from "jotai";
|
||||
import { AgentAvatarStack, agentGlyphBackground } from "./agent-avatar-stack";
|
||||
import { AgentAvatarStack } from "./agent-avatar-stack";
|
||||
import { avatarStyle } from "@/lib/avatar-palette";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
@@ -13,14 +14,16 @@ import {
|
||||
|
||||
type Props = React.ComponentProps<typeof AgentAvatarStack>;
|
||||
|
||||
// The DOM normalizes an inline `background: hsl(...)` to `rgb(...)`. Push the
|
||||
// The DOM normalizes an inline hex `background-color` to `rgb(...)`. Push the
|
||||
// expected color through the same CSSOM path so the comparison stays exact and
|
||||
// non-vacuous (an empty string — i.e. no inline background, as in the pre-fix
|
||||
// Avatar approach — can never match a real color).
|
||||
// Avatar approach — can never match a real color). NOTE: jsdom's CSSOM does not
|
||||
// round-trip a `linear-gradient` in the `background` shorthand, which is why the
|
||||
// glyph carries an explicit solid `background-color` we assert on here.
|
||||
function normalizeColor(value: string): string {
|
||||
const probe = document.createElement("div");
|
||||
probe.style.background = value;
|
||||
return probe.style.background;
|
||||
probe.style.backgroundColor = value;
|
||||
return probe.style.backgroundColor;
|
||||
}
|
||||
|
||||
function renderStack(props: Props) {
|
||||
@@ -36,26 +39,6 @@ function renderStack(props: Props) {
|
||||
return { store, ...utils };
|
||||
}
|
||||
|
||||
describe("agentGlyphBackground", () => {
|
||||
it("is deterministic for a given agent name", () => {
|
||||
expect(agentGlyphBackground("Researcher")).toBe(
|
||||
agentGlyphBackground("Researcher"),
|
||||
);
|
||||
});
|
||||
|
||||
it("gives categorically different colors to different agents", () => {
|
||||
// The two agents that looked identically violet in the report must differ.
|
||||
expect(agentGlyphBackground("Структурный редактор")).not.toBe(
|
||||
agentGlyphBackground("Фактчекер"),
|
||||
);
|
||||
expect(agentGlyphBackground("Researcher")).not.toBe(
|
||||
agentGlyphBackground("Нарратор"),
|
||||
);
|
||||
// Every color is a dark hsl circle drawn from the palette.
|
||||
expect(agentGlyphBackground("Нарратор")).toMatch(/^hsl\(\d+, \d+%, \d+%\)$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentAvatarStack", () => {
|
||||
it("internal chat WITH role: emoji glyph + human launcher badge in front", () => {
|
||||
const { container } = renderStack({
|
||||
@@ -73,8 +56,8 @@ describe("AgentAvatarStack", () => {
|
||||
expect(screen.getByText("Alice")).toBeDefined();
|
||||
});
|
||||
|
||||
it("emoji glyph applies its per-agent color as an inline DOM background", () => {
|
||||
// Pins the actual fix: the hashed color must reach the DOM as an inline
|
||||
it("emoji glyph applies its per-agent gradient as an inline DOM background", () => {
|
||||
// Pins the actual fix: the hashed gradient must reach the DOM as an inline
|
||||
// `background` on the glyph Box. The pre-fix `Avatar variant="filled"` set no
|
||||
// inline background (Mantine's --avatar-bg overrode it), so this fails there.
|
||||
const agent = { name: "Researcher", emoji: "🔬", avatarUrl: null };
|
||||
@@ -88,20 +71,19 @@ describe("AgentAvatarStack", () => {
|
||||
'[data-testid="agent-glyph"]',
|
||||
);
|
||||
expect(glyph).not.toBeNull();
|
||||
// Non-vacuous: compare against the function output (normalized the same way),
|
||||
// not a frozen literal. Empty against the pre-fix Avatar (no inline bg).
|
||||
expect(glyph!.style.background).not.toBe("");
|
||||
expect(glyph!.style.background).toBe(
|
||||
normalizeColor(agentGlyphBackground(agent.name)),
|
||||
);
|
||||
const expected = normalizeColor(avatarStyle(agent.name).bg);
|
||||
// Non-vacuous: the pre-fix Avatar set no inline background at all.
|
||||
expect(expected).not.toBe("");
|
||||
expect(glyph!.style.backgroundColor).toBe(expected);
|
||||
// (The gradient overlay is a browser-only enhancement — jsdom's CSSOM does
|
||||
// not round-trip linear-gradient — so its stops/angle are covered by the
|
||||
// avatarStyle unit tests above, not asserted on the DOM here.)
|
||||
});
|
||||
|
||||
it("agents with distinct hashed colors reach the DOM as distinct backgrounds", () => {
|
||||
it("agents with distinct styles reach the DOM as distinct backgrounds", () => {
|
||||
// "Researcher" and "Нарратор" hash to different palette entries, so their
|
||||
// applied DOM backgrounds must differ — pins "distinct colors reach the DOM".
|
||||
expect(agentGlyphBackground("Researcher")).not.toBe(
|
||||
agentGlyphBackground("Нарратор"),
|
||||
);
|
||||
expect(avatarStyle("Researcher").bg).not.toBe(avatarStyle("Нарратор").bg);
|
||||
|
||||
const a = renderStack({
|
||||
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
|
||||
@@ -120,14 +102,9 @@ describe("AgentAvatarStack", () => {
|
||||
const glyphB = b.container.querySelector<HTMLElement>(
|
||||
'[data-testid="agent-glyph"]',
|
||||
);
|
||||
expect(glyphA!.style.background).toBe(
|
||||
normalizeColor(agentGlyphBackground("Researcher")),
|
||||
);
|
||||
expect(glyphB!.style.background).toBe(
|
||||
normalizeColor(agentGlyphBackground("Нарратор")),
|
||||
);
|
||||
// Different colors reach the DOM (the normalized rgb values also differ).
|
||||
expect(glyphA!.style.background).not.toBe(glyphB!.style.background);
|
||||
expect(glyphA!.style.backgroundColor).not.toBe("");
|
||||
// Different base colors reach the DOM (the serialized rgb values differ).
|
||||
expect(glyphA!.style.backgroundColor).not.toBe(glyphB!.style.backgroundColor);
|
||||
});
|
||||
|
||||
it("showName=false: renders only the avatars, no inline name label", () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { avatarStyle, avatarBackgroundCss } from "@/lib/avatar-palette";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
@@ -29,54 +30,11 @@ const LAUNCHER_SIZE = 22;
|
||||
// sits as a small badge over that corner (above the glyph) and stays fully visible.
|
||||
const LAUNCHER_OVERHANG = 8;
|
||||
|
||||
// Small deterministic string hash (same algorithm as custom-avatar's initials
|
||||
// hash) used to pick a stable per-agent glyph color.
|
||||
function hashName(input: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
hash = (hash << 5) - hash + input.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
// A palette of categorically-DISTINCT dark circle colors for emoji/sparkles agent
|
||||
// glyphs. Every entry is intentionally dark (low lightness) so a bright emoji or
|
||||
// the white sparkles icon stays readable on top; the hues are spread across the
|
||||
// wheel (red → orange → amber → green → teal → cyan → blue → indigo → violet →
|
||||
// magenta + a neutral slate) so two different agents read as DIFFERENT colors,
|
||||
// not merely different shades of the same violet.
|
||||
const GLYPH_COLORS = [
|
||||
"hsl(355, 60%, 34%)", // red
|
||||
"hsl(18, 62%, 32%)", // vermilion
|
||||
"hsl(32, 60%, 30%)", // orange
|
||||
"hsl(45, 55%, 28%)", // amber
|
||||
"hsl(75, 45%, 26%)", // olive-green
|
||||
"hsl(140, 48%, 26%)", // green
|
||||
"hsl(165, 52%, 26%)", // teal
|
||||
"hsl(188, 58%, 28%)", // cyan
|
||||
"hsl(205, 58%, 32%)", // sky blue
|
||||
"hsl(225, 52%, 36%)", // blue
|
||||
"hsl(250, 48%, 38%)", // indigo
|
||||
"hsl(280, 46%, 36%)", // violet
|
||||
"hsl(312, 48%, 34%)", // magenta
|
||||
"hsl(210, 12%, 36%)", // slate / neutral
|
||||
];
|
||||
|
||||
/**
|
||||
* Deterministic dark circle color for an emoji/sparkles agent glyph, picked from
|
||||
* GLYPH_COLORS by a hash of the agent name so distinct agents get categorically
|
||||
* distinct colors while every color stays dark enough to keep the glyph readable.
|
||||
*/
|
||||
export function agentGlyphBackground(name: string): string {
|
||||
return GLYPH_COLORS[hashName(name) % GLYPH_COLORS.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* The front avatar. Image-source priority (#300):
|
||||
* 1. agent.avatarUrl -> a real avatar image (external MCP agent account).
|
||||
* 2. agent.emoji -> the role emoji on a per-agent dark circle.
|
||||
* 3. otherwise -> the IconSparkles glyph on a per-agent dark circle (fallback).
|
||||
* 2. agent.emoji -> the role emoji on a per-agent gradient circle.
|
||||
* 3. otherwise -> the IconSparkles glyph on a per-agent gradient circle.
|
||||
*/
|
||||
function AgentGlyph({ agent }: { agent: AgentInfo }) {
|
||||
if (agent.avatarUrl) {
|
||||
@@ -89,10 +47,13 @@ function AgentGlyph({ agent }: { agent: AgentInfo }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Emoji/sparkles glyph on a per-agent dark circle (color hashed from the agent
|
||||
// name). Rendered as a plain Box, NOT a Mantine `Avatar variant="filled"`, so
|
||||
// the background is guaranteed instead of being overridden by Mantine's
|
||||
// `--avatar-bg` (which was falling back to the theme's violet for every agent).
|
||||
// Emoji/sparkles glyph on a per-agent gradient circle (color, gradient partner
|
||||
// and split angle all hashed from the agent name via avatarStyle — see
|
||||
// @/lib/avatar-palette). Rendered as a plain Box, NOT a Mantine
|
||||
// `Avatar variant="filled"` — Mantine's `--avatar-bg` overrode the background
|
||||
// (every agent fell back to the theme's violet). The foreground (the sparkles
|
||||
// icon) uses the ring's WCAG-checked readable text color.
|
||||
const style = avatarStyle(agent.name);
|
||||
return (
|
||||
<Box
|
||||
data-testid="agent-glyph"
|
||||
@@ -100,8 +61,14 @@ function AgentGlyph({ agent }: { agent: AgentInfo }) {
|
||||
width: GLYPH_SIZE,
|
||||
height: GLYPH_SIZE,
|
||||
borderRadius: "50%",
|
||||
background: agentGlyphBackground(agent.name),
|
||||
color: "var(--mantine-color-white)",
|
||||
// Solid base color is the fallback (and the testable value); the gradient
|
||||
// paints over it in browsers that support it.
|
||||
backgroundColor: style.bg,
|
||||
backgroundImage: avatarBackgroundCss(style),
|
||||
color:
|
||||
style.text === "white"
|
||||
? "var(--mantine-color-white)"
|
||||
: "var(--mantine-color-black)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// A disabled mic must explain WHY it is unavailable rather than silently saying
|
||||
// "Start dictation". This renders MicButton in its idle+disabled state with a
|
||||
// forwarded reason and asserts the accessible label resolves to that reason's
|
||||
// text via the shared resolver (dictation-status.resolveUnavailableLabel).
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
// Pass i18n keys through verbatim so we assert the exact resolved string.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (s: string) => s }),
|
||||
}));
|
||||
|
||||
// Keep both controllers inert and idle so MicButton renders the idle branch.
|
||||
const idleCtl = {
|
||||
status: "idle" as const,
|
||||
start: vi.fn(async () => {}),
|
||||
stop: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
audioLevel: 0,
|
||||
errorMessage: null,
|
||||
};
|
||||
vi.mock("@/features/dictation/hooks/use-dictation", () => ({
|
||||
useDictation: () => idleCtl,
|
||||
}));
|
||||
vi.mock("@/features/dictation/hooks/use-streaming-dictation", () => ({
|
||||
useStreamingDictation: () => idleCtl,
|
||||
}));
|
||||
|
||||
import { MicButton } from "./mic-button";
|
||||
|
||||
function renderButton(props: React.ComponentProps<typeof MicButton>) {
|
||||
render(
|
||||
<MantineProvider>
|
||||
<MicButton {...props} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MicButton — disabled reason label", () => {
|
||||
// jsdom has no MediaRecorder / mediaDevices, so isDictationSupported() would
|
||||
// report "unsupported" and mask the forwarded reason. Stub both so the button
|
||||
// is considered supported and the availability reason is what surfaces.
|
||||
beforeEach(() => {
|
||||
(globalThis as unknown as { MediaRecorder: unknown }).MediaRecorder =
|
||||
class {};
|
||||
Object.defineProperty(navigator, "mediaDevices", {
|
||||
configurable: true,
|
||||
value: { getUserMedia: vi.fn() },
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
delete (globalThis as unknown as { MediaRecorder?: unknown }).MediaRecorder;
|
||||
});
|
||||
|
||||
it("shows the cause-specific reason instead of 'Start dictation' when disabled with a reason", () => {
|
||||
renderButton({ onText: () => {}, disabled: true, unavailableReason: "offline" });
|
||||
const expected =
|
||||
"No connection to the collaboration server — dictation unavailable";
|
||||
// The reason surfaces as the accessible label (and the tooltip text).
|
||||
const button = screen.getByRole("button", { name: expected });
|
||||
expect(button).toBeDefined();
|
||||
// It is marked disabled the Mantine way (data-disabled), NOT the native
|
||||
// `disabled` attribute — otherwise pointer-events:none would kill the tooltip.
|
||||
expect(button.getAttribute("data-disabled")).toBe("true");
|
||||
expect(button.hasAttribute("disabled")).toBe(false);
|
||||
// And it no longer silently reads "Start dictation".
|
||||
expect(screen.queryByRole("button", { name: "Start dictation" })).toBeNull();
|
||||
});
|
||||
|
||||
it("reads 'Start dictation' when enabled with no reason", () => {
|
||||
renderButton({ onText: () => {} });
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Start dictation" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not advertise 'Start dictation' when disabled with no reason", () => {
|
||||
// A consumer passing bare `disabled` (e.g. the AI chat's isStreaming) with no
|
||||
// unavailableReason must not get a hoverable mic whose tooltip invites
|
||||
// "Start dictation" on a click that is rejected.
|
||||
renderButton({ onText: () => {}, disabled: true });
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Start dictation" }),
|
||||
).toBeNull();
|
||||
const button = screen.getByRole("button");
|
||||
expect(button.getAttribute("data-disabled")).toBe("true");
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,11 @@ import { IconMicrophone, IconPlayerStopFilled } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDictation } from "@/features/dictation/hooks/use-dictation";
|
||||
import { useStreamingDictation } from "@/features/dictation/hooks/use-streaming-dictation";
|
||||
import {
|
||||
isDictationSupported,
|
||||
resolveUnavailableLabel,
|
||||
type DictationUnavailableReason,
|
||||
} from "@/features/dictation/dictation-status";
|
||||
import classes from "./mic-button.module.css";
|
||||
|
||||
interface MicButtonProps {
|
||||
@@ -21,6 +26,9 @@ interface MicButtonProps {
|
||||
// When true, use the streaming (Silero-VAD) dictation controller, which emits
|
||||
// text progressively as the user pauses; otherwise use the batch controller.
|
||||
streaming?: boolean;
|
||||
// When the mic is disabled for an availability reason, this is the cause the
|
||||
// idle tooltip explains (e.g. pre-sync "connecting", "offline", "read-only").
|
||||
unavailableReason?: DictationUnavailableReason;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,6 +45,7 @@ export const MicButton: FC<MicButtonProps> = ({
|
||||
color,
|
||||
iconSize,
|
||||
streaming = false,
|
||||
unavailableReason,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// Call BOTH hooks unconditionally to respect the rules of hooks: which one is
|
||||
@@ -46,7 +55,7 @@ export const MicButton: FC<MicButtonProps> = ({
|
||||
const batchCtl = useDictation({ onText, onStart });
|
||||
const streamingCtl = useStreamingDictation({ onText, onStart });
|
||||
const ctl = streaming ? streamingCtl : batchCtl;
|
||||
const { status, start, stop, audioLevel } = ctl;
|
||||
const { status, start, stop, audioLevel, errorMessage } = ctl;
|
||||
const resolvedIconSize = iconSize ?? (size === "lg" ? 18 : 16);
|
||||
|
||||
if (status === "recording") {
|
||||
@@ -82,15 +91,28 @@ export const MicButton: FC<MicButtonProps> = ({
|
||||
) {
|
||||
// "loading" (streaming hook fetching the VAD model on first use) shows the
|
||||
// same spinner+disabled state so the first click is visibly acknowledged and
|
||||
// a confusing second click can't fire while the model loads.
|
||||
const label = status === "loading" ? t("Preparing…") : t("Transcribing…");
|
||||
// a confusing second click can't fire while the model loads. The error case
|
||||
// explains the failure via the hook's resolved errorMessage instead of the
|
||||
// transient "Transcribing…" label.
|
||||
const label =
|
||||
status === "error"
|
||||
? (errorMessage ?? t("Transcription failed"))
|
||||
: status === "loading"
|
||||
? t("Preparing…")
|
||||
: t("Transcribing…");
|
||||
return (
|
||||
<Tooltip label={label} withArrow>
|
||||
<ActionIcon
|
||||
size={size}
|
||||
variant="subtle"
|
||||
color={color}
|
||||
disabled
|
||||
// Mark disabled the Mantine way (data-disabled/aria-disabled) rather
|
||||
// than the native `disabled` attribute: native `disabled` sets
|
||||
// `pointer-events:none`, which suppresses hover so the Tooltip never
|
||||
// fires. This is a status display with no click action to guard, so
|
||||
// keeping it hoverable simply lets the error reason be read on hover.
|
||||
data-disabled
|
||||
aria-disabled
|
||||
aria-label={label}
|
||||
>
|
||||
<Loader size="xs" />
|
||||
@@ -99,18 +121,56 @@ export const MicButton: FC<MicButtonProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Idle branch. A grey/disabled mic must explain WHY it can't record. An
|
||||
// unsupported browser/context is detected here; otherwise the parent forwards
|
||||
// a cause-specific reason. We must NOT pass the native `disabled` prop: Mantine
|
||||
// renders `<button disabled>` with `pointer-events:none`, which suppresses
|
||||
// hover so the Tooltip never fires. Instead mark it disabled the Mantine way
|
||||
// (data-disabled/aria-disabled) — keeping it hoverable and in the a11y tree —
|
||||
// and guard the click ourselves.
|
||||
const unsupported = !isDictationSupported();
|
||||
const isDisabled = disabled || unsupported;
|
||||
const reason: DictationUnavailableReason | undefined = unsupported
|
||||
? "unsupported"
|
||||
: unavailableReason;
|
||||
const reasonLabel = reason ? resolveUnavailableLabel(reason, t) : undefined;
|
||||
// A disabled mic with a known reason surfaces it on hover; an enabled mic
|
||||
// invites "Start dictation". But a mic disabled with NO reason (e.g. a
|
||||
// consumer that passes bare `disabled` — the AI chat's isStreaming, with no
|
||||
// unavailableReason) must NOT hover a misleading, actionable "Start dictation"
|
||||
// tooltip on a control that rejects the click. In that case we render the icon
|
||||
// without a Tooltip and give it a neutral accessible label instead.
|
||||
const ariaLabel = reasonLabel ?? (isDisabled ? t("Dictation") : t("Start dictation"));
|
||||
const icon = (
|
||||
<ActionIcon
|
||||
size={size}
|
||||
variant="subtle"
|
||||
color={color}
|
||||
onClick={(e) => {
|
||||
if (isDisabled) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
void start();
|
||||
}}
|
||||
data-disabled={isDisabled || undefined}
|
||||
aria-disabled={isDisabled}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<IconMicrophone size={resolvedIconSize} />
|
||||
</ActionIcon>
|
||||
);
|
||||
// Suppress the tooltip on a disabled mic that has nothing to explain — hovering
|
||||
// a grey, unclickable mic should not advertise "Start dictation".
|
||||
if (isDisabled && !reasonLabel) {
|
||||
return icon;
|
||||
}
|
||||
return (
|
||||
<Tooltip label={t("Start dictation")} withArrow>
|
||||
<ActionIcon
|
||||
size={size}
|
||||
variant="subtle"
|
||||
color={color}
|
||||
onClick={() => void start()}
|
||||
disabled={disabled}
|
||||
aria-label={t("Start dictation")}
|
||||
>
|
||||
<IconMicrophone size={resolvedIconSize} />
|
||||
</ActionIcon>
|
||||
<Tooltip
|
||||
label={reasonLabel ?? t("Start dictation")}
|
||||
withArrow
|
||||
>
|
||||
{icon}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
classifyGetUserMediaError,
|
||||
classifyTranscriptionError,
|
||||
dictationErrorMessage,
|
||||
resolveUnavailableLabel,
|
||||
isDictationSupported,
|
||||
} from "./dictation-status";
|
||||
|
||||
// Unit tests for the shared dictation-status resolvers (dictation-status.ts).
|
||||
// Both dictation hooks and the mic button form their user-facing strings here,
|
||||
// so a regression in the classification or message mapping would silently swap
|
||||
// what a user reads when the mic is grey or a recording fails. A fake `t`
|
||||
// returns its key verbatim so we assert the exact i18n key each branch selects.
|
||||
const t = (k: string) => k;
|
||||
|
||||
describe("classifyGetUserMediaError", () => {
|
||||
it("maps NotAllowedError / SecurityError to mic-denied", () => {
|
||||
expect(classifyGetUserMediaError({ name: "NotAllowedError" })).toBe(
|
||||
"mic-denied",
|
||||
);
|
||||
expect(classifyGetUserMediaError({ name: "SecurityError" })).toBe(
|
||||
"mic-denied",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps NotFoundError / OverconstrainedError to no-mic", () => {
|
||||
expect(classifyGetUserMediaError({ name: "NotFoundError" })).toBe("no-mic");
|
||||
expect(classifyGetUserMediaError({ name: "OverconstrainedError" })).toBe(
|
||||
"no-mic",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps NotReadableError / AbortError to mic-in-use", () => {
|
||||
expect(classifyGetUserMediaError({ name: "NotReadableError" })).toBe(
|
||||
"mic-in-use",
|
||||
);
|
||||
expect(classifyGetUserMediaError({ name: "AbortError" })).toBe(
|
||||
"mic-in-use",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps anything else / undefined to unknown", () => {
|
||||
expect(classifyGetUserMediaError({ name: "WeirdError" })).toBe("unknown");
|
||||
expect(classifyGetUserMediaError(undefined)).toBe("unknown");
|
||||
expect(classifyGetUserMediaError({})).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyTranscriptionError", () => {
|
||||
it("returns the verbatim server message when present", () => {
|
||||
const err = { response: { status: 500, data: { message: "provider 404" } } };
|
||||
expect(classifyTranscriptionError(err)).toEqual({
|
||||
code: "transcription-failed",
|
||||
serverMessage: "provider 404",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps 503 / 403 (no server message) to stt-not-configured", () => {
|
||||
expect(classifyTranscriptionError({ response: { status: 503 } })).toEqual({
|
||||
code: "stt-not-configured",
|
||||
});
|
||||
expect(classifyTranscriptionError({ response: { status: 403 } })).toEqual({
|
||||
code: "stt-not-configured",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to transcription-failed with no server message otherwise", () => {
|
||||
expect(classifyTranscriptionError({ response: { status: 500 } })).toEqual({
|
||||
code: "transcription-failed",
|
||||
});
|
||||
expect(classifyTranscriptionError(new Error("network"))).toEqual({
|
||||
code: "transcription-failed",
|
||||
});
|
||||
// Blank server message is ignored (does not win as verbatim text).
|
||||
expect(
|
||||
classifyTranscriptionError({ response: { data: { message: " " } } }),
|
||||
).toEqual({ code: "transcription-failed" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("dictationErrorMessage", () => {
|
||||
it("maps each code to the expected i18n key", () => {
|
||||
expect(dictationErrorMessage("mic-denied", t)).toBe(
|
||||
"Microphone access denied",
|
||||
);
|
||||
expect(dictationErrorMessage("no-mic", t)).toBe("No microphone found");
|
||||
expect(dictationErrorMessage("mic-in-use", t)).toBe(
|
||||
"Microphone is unavailable or already in use",
|
||||
);
|
||||
expect(dictationErrorMessage("no-media-devices", t)).toBe(
|
||||
"Audio recording is not available in this browser/context",
|
||||
);
|
||||
expect(dictationErrorMessage("stt-not-configured", t)).toBe(
|
||||
"Voice dictation is not configured",
|
||||
);
|
||||
expect(dictationErrorMessage("transcription-failed", t)).toBe(
|
||||
"Transcription failed",
|
||||
);
|
||||
expect(dictationErrorMessage("recorder-failed", t)).toBe(
|
||||
"Could not start recording",
|
||||
);
|
||||
expect(dictationErrorMessage("vad-init-failed", t)).toBe(
|
||||
"Could not start recording",
|
||||
);
|
||||
expect(dictationErrorMessage("unknown", t)).toBe(
|
||||
"Could not start recording",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the server message verbatim for transcription-failed (not the t key)", () => {
|
||||
expect(
|
||||
dictationErrorMessage("transcription-failed", t, {
|
||||
serverMessage: "quota exceeded",
|
||||
}),
|
||||
).toBe("quota exceeded");
|
||||
});
|
||||
|
||||
it("appends the detail to recorder-failed / unknown", () => {
|
||||
expect(
|
||||
dictationErrorMessage("recorder-failed", t, { detail: "boom" }),
|
||||
).toBe("Could not start recording: boom");
|
||||
expect(dictationErrorMessage("unknown", t, { detail: "nope" })).toBe(
|
||||
"Could not start recording: nope",
|
||||
);
|
||||
});
|
||||
|
||||
it("appends the detail to transcription-failed when there is no server message", () => {
|
||||
expect(
|
||||
dictationErrorMessage("transcription-failed", t, { detail: "timeout" }),
|
||||
).toBe("Transcription failed: timeout");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveUnavailableLabel", () => {
|
||||
it("maps each reason to its expected i18n key", () => {
|
||||
expect(resolveUnavailableLabel("connecting", t)).toBe(
|
||||
"Dictation becomes available once the page finishes connecting",
|
||||
);
|
||||
expect(resolveUnavailableLabel("offline", t)).toBe(
|
||||
"No connection to the collaboration server — dictation unavailable",
|
||||
);
|
||||
expect(resolveUnavailableLabel("read-only", t)).toBe(
|
||||
"This page is read-only",
|
||||
);
|
||||
expect(resolveUnavailableLabel("unsupported", t)).toBe(
|
||||
"Audio recording is not available in this browser/context",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDictationSupported", () => {
|
||||
it("returns a boolean", () => {
|
||||
expect(typeof isDictationSupported()).toBe("boolean");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
// Single source of truth for "why dictation is unavailable" and "why it failed".
|
||||
// Both dictation hooks and the mic button pull their user-facing strings from
|
||||
// the resolvers here so the wording lives in exactly one place.
|
||||
|
||||
export type DictationUnavailableReason =
|
||||
| "connecting"
|
||||
| "offline"
|
||||
| "read-only"
|
||||
| "unsupported";
|
||||
|
||||
export type DictationErrorCode =
|
||||
| "no-media-devices"
|
||||
| "mic-denied"
|
||||
| "no-mic"
|
||||
| "mic-in-use"
|
||||
| "recorder-failed"
|
||||
| "vad-init-failed"
|
||||
| "stt-not-configured"
|
||||
| "transcription-failed"
|
||||
| "unknown";
|
||||
|
||||
// True if this browser/context can record audio.
|
||||
export function isDictationSupported(): boolean {
|
||||
return (
|
||||
typeof MediaRecorder !== "undefined" &&
|
||||
typeof navigator !== "undefined" &&
|
||||
!!navigator.mediaDevices?.getUserMedia
|
||||
);
|
||||
}
|
||||
|
||||
// getUserMedia / VAD.start rejection -> code, by DOMException .name.
|
||||
export function classifyGetUserMediaError(err: unknown): DictationErrorCode {
|
||||
const name = (err as { name?: string })?.name;
|
||||
if (name === "NotAllowedError" || name === "SecurityError")
|
||||
return "mic-denied";
|
||||
if (name === "NotFoundError" || name === "OverconstrainedError")
|
||||
return "no-mic";
|
||||
if (name === "NotReadableError" || name === "AbortError") return "mic-in-use";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
// Transcription HTTP failure -> code (+ verbatim server message when present).
|
||||
export function classifyTranscriptionError(err: unknown): {
|
||||
code: DictationErrorCode;
|
||||
serverMessage?: string;
|
||||
} {
|
||||
const resp = (
|
||||
err as { response?: { status?: number; data?: { message?: string } } }
|
||||
)?.response;
|
||||
const serverMessage = resp?.data?.message;
|
||||
if (serverMessage && serverMessage.trim().length > 0)
|
||||
return { code: "transcription-failed", serverMessage };
|
||||
if (resp?.status === 503 || resp?.status === 403)
|
||||
return { code: "stt-not-configured" };
|
||||
return { code: "transcription-failed" };
|
||||
}
|
||||
|
||||
type TFn = (key: string) => string;
|
||||
|
||||
// Code -> user text. The ONE place runtime error strings are formed.
|
||||
// serverMessage (verbatim) wins for transcription-failed; detail is appended
|
||||
// to the generic "could not start"/"transcription failed" strings.
|
||||
export function dictationErrorMessage(
|
||||
code: DictationErrorCode,
|
||||
t: TFn,
|
||||
extra?: { serverMessage?: string; detail?: string },
|
||||
): string {
|
||||
const detail = extra?.detail;
|
||||
switch (code) {
|
||||
case "mic-denied":
|
||||
return t("Microphone access denied");
|
||||
case "no-mic":
|
||||
return t("No microphone found");
|
||||
case "mic-in-use":
|
||||
return t("Microphone is unavailable or already in use");
|
||||
case "no-media-devices":
|
||||
return t("Audio recording is not available in this browser/context");
|
||||
case "stt-not-configured":
|
||||
return t("Voice dictation is not configured");
|
||||
case "transcription-failed":
|
||||
if (extra?.serverMessage && extra.serverMessage.trim().length > 0)
|
||||
return extra.serverMessage;
|
||||
return `${t("Transcription failed")}${detail ? `: ${detail}` : ""}`;
|
||||
case "recorder-failed":
|
||||
case "vad-init-failed":
|
||||
case "unknown":
|
||||
default:
|
||||
return `${t("Could not start recording")}${detail ? `: ${detail}` : ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Unavailable reason -> tooltip text (the ONE place these strings are formed).
|
||||
export function resolveUnavailableLabel(
|
||||
r: DictationUnavailableReason,
|
||||
t: TFn,
|
||||
): string {
|
||||
switch (r) {
|
||||
case "connecting":
|
||||
return t("Dictation becomes available once the page finishes connecting");
|
||||
case "offline":
|
||||
return t(
|
||||
"No connection to the collaboration server — dictation unavailable",
|
||||
);
|
||||
case "read-only":
|
||||
return t("This page is read-only");
|
||||
case "unsupported":
|
||||
default:
|
||||
return t("Audio recording is not available in this browser/context");
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { transcribeAudio } from "@/features/dictation/services/dictation-service";
|
||||
import {
|
||||
classifyGetUserMediaError,
|
||||
classifyTranscriptionError,
|
||||
dictationErrorMessage,
|
||||
} from "@/features/dictation/dictation-status";
|
||||
|
||||
// "loading" is set only by the streaming hook while it lazily loads the VAD
|
||||
// model on first use; the batch hook never sets it. It exists so the streaming
|
||||
@@ -26,6 +31,8 @@ interface UseDictationResult {
|
||||
cancel: () => void;
|
||||
// Smoothed live microphone level in the 0..1 range while recording (0 when idle).
|
||||
audioLevel: number;
|
||||
// The last error shown to the user (null until one occurs / on a new start).
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
// Candidate container/codec combinations in preference order. The first one the
|
||||
@@ -67,6 +74,8 @@ export function useDictation(
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<DictationStatus>("idle");
|
||||
const [audioLevel, setAudioLevel] = useState(0);
|
||||
// Last error message shown to the user; the mic button reads it for its tooltip.
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Keep the latest callbacks in a ref so the recorder's onstop closure always
|
||||
// calls the current handlers without re-creating the recorder.
|
||||
@@ -194,15 +203,16 @@ export function useDictation(
|
||||
if (startingRef.current || recorderRef.current || streamRef.current) return;
|
||||
if (status !== "idle") return;
|
||||
startingRef.current = true;
|
||||
// Clear any stale error from a previous attempt.
|
||||
setErrorMessage(null);
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
const reason =
|
||||
"navigator.mediaDevices.getUserMedia is unavailable in this context";
|
||||
console.error("[dictation] " + reason);
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: t("Audio recording is not available in this browser/context"),
|
||||
});
|
||||
const message = dictationErrorMessage("no-media-devices", t);
|
||||
notifications.show({ color: "red", message });
|
||||
setErrorMessage(message);
|
||||
setStatus("idle");
|
||||
startingRef.current = false;
|
||||
return;
|
||||
@@ -215,19 +225,16 @@ export function useDictation(
|
||||
// Always log the full error for diagnosis (name, message, stack).
|
||||
console.error("[dictation] getUserMedia failed", err);
|
||||
const name = (err as { name?: string })?.name;
|
||||
const detail = (err as { message?: string })?.message ?? String(err);
|
||||
let message: string;
|
||||
if (name === "NotAllowedError" || name === "SecurityError") {
|
||||
message = t("Microphone access denied");
|
||||
} else if (name === "NotFoundError" || name === "OverconstrainedError") {
|
||||
message = t("No microphone found");
|
||||
} else if (name === "NotReadableError" || name === "AbortError") {
|
||||
message = t("Microphone is unavailable or already in use");
|
||||
} else {
|
||||
// Unknown failure: show the real reason instead of a generic string.
|
||||
message = `${t("Could not start recording")}: ${name ? `${name}: ` : ""}${detail}`;
|
||||
}
|
||||
const rawDetail = (err as { message?: string })?.message ?? String(err);
|
||||
// Prefix the DOMException name (e.g. "TypeError: …") so the generic
|
||||
// resolver branch reproduces this hook's original "Could not start
|
||||
// recording: <name>: <detail>" text. Each caller owns its own detail; the
|
||||
// streaming hook intentionally does not add the name.
|
||||
const detail = `${name ? `${name}: ` : ""}${rawDetail}`;
|
||||
const code = classifyGetUserMediaError(err);
|
||||
const message = dictationErrorMessage(code, t, { detail });
|
||||
notifications.show({ color: "red", message });
|
||||
setErrorMessage(message);
|
||||
setStatus("idle");
|
||||
startingRef.current = false;
|
||||
return;
|
||||
@@ -249,10 +256,10 @@ export function useDictation(
|
||||
// The stream was acquired but the recorder failed to construct; stop the
|
||||
// tracks so the MediaStream does not leak before bailing out.
|
||||
stopTracks();
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: `${t("Could not start recording")}: ${(err as { message?: string })?.message ?? String(err)}`,
|
||||
});
|
||||
const detail = (err as { message?: string })?.message ?? String(err);
|
||||
const message = dictationErrorMessage("recorder-failed", t, { detail });
|
||||
notifications.show({ color: "red", message });
|
||||
setErrorMessage(message);
|
||||
setStatus("idle");
|
||||
startingRef.current = false;
|
||||
return;
|
||||
@@ -293,21 +300,14 @@ export function useDictation(
|
||||
.catch((err: unknown) => {
|
||||
// Log the full error for diagnosis (status + body + stack).
|
||||
console.error("[dictation] transcription failed", err);
|
||||
const resp = (
|
||||
err as { response?: { status?: number; data?: { message?: string } } }
|
||||
)?.response;
|
||||
const serverMsg = resp?.data?.message;
|
||||
let message: string;
|
||||
if (serverMsg && serverMsg.trim().length > 0) {
|
||||
// The server already explains the cause (e.g. provider 404, bad
|
||||
// format, STT not configured) — show it verbatim.
|
||||
message = serverMsg;
|
||||
} else if (resp?.status === 503 || resp?.status === 403) {
|
||||
message = t("Voice dictation is not configured");
|
||||
} else {
|
||||
message = `${t("Transcription failed")}: ${(err as { message?: string })?.message ?? String(err)}`;
|
||||
}
|
||||
const { code, serverMessage } = classifyTranscriptionError(err);
|
||||
const detail = (err as { message?: string })?.message ?? String(err);
|
||||
const message = dictationErrorMessage(code, t, {
|
||||
serverMessage,
|
||||
detail,
|
||||
});
|
||||
notifications.show({ color: "red", message });
|
||||
setErrorMessage(message);
|
||||
setStatus("error");
|
||||
if (errorTimerRef.current !== null) {
|
||||
clearTimeout(errorTimerRef.current);
|
||||
@@ -332,10 +332,10 @@ export function useDictation(
|
||||
stopTracks();
|
||||
recorderRef.current = null;
|
||||
startingRef.current = false;
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: `${t("Could not start recording")}: ${(err as { message?: string })?.message ?? String(err)}`,
|
||||
});
|
||||
const detail = (err as { message?: string })?.message ?? String(err);
|
||||
const message = dictationErrorMessage("recorder-failed", t, { detail });
|
||||
notifications.show({ color: "red", message });
|
||||
setErrorMessage(message);
|
||||
setStatus("idle");
|
||||
return;
|
||||
}
|
||||
@@ -405,5 +405,5 @@ export function useDictation(
|
||||
};
|
||||
}, [clearTimer, stopTracks, stopMeter]);
|
||||
|
||||
return { status, start, stop, cancel, audioLevel };
|
||||
return { status, start, stop, cancel, audioLevel, errorMessage };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ import { useTranslation } from "react-i18next";
|
||||
import { transcribeAudio } from "@/features/dictation/services/dictation-service";
|
||||
import { encodeWavPcm16 } from "@/features/dictation/utils/encode-wav";
|
||||
import type { DictationStatus } from "@/features/dictation/hooks/use-dictation";
|
||||
import {
|
||||
classifyGetUserMediaError,
|
||||
classifyTranscriptionError,
|
||||
dictationErrorMessage,
|
||||
} from "@/features/dictation/dictation-status";
|
||||
|
||||
// Lazily-imported MicVAD type. The runtime import happens inside start() so the
|
||||
// heavy onnxruntime-web / Silero model is code-split out of the main bundle and
|
||||
@@ -27,6 +32,8 @@ interface UseStreamingDictationResult {
|
||||
cancel: () => void;
|
||||
// Smoothed live speech level in the 0..1 range while recording (0 when idle).
|
||||
audioLevel: number;
|
||||
// The last error shown to the user (null until one occurs / on a new start).
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
// Sample rate of the audio MicVAD hands to onSpeechEnd (Silero VAD runs at 16k).
|
||||
@@ -60,6 +67,8 @@ export function useStreamingDictation(
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<DictationStatus>("idle");
|
||||
const [audioLevel, setAudioLevel] = useState(0);
|
||||
// Last error message shown to the user; the mic button reads it for its tooltip.
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Keep the latest callbacks in a ref so async VAD/HTTP closures always call the
|
||||
// current handlers without re-creating the VAD.
|
||||
@@ -158,26 +167,6 @@ export function useStreamingDictation(
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Map a transcription error to a user-facing message, mirroring the batch hook.
|
||||
const transcriptionErrorMessage = useCallback(
|
||||
(err: unknown): string => {
|
||||
const resp = (
|
||||
err as { response?: { status?: number; data?: { message?: string } } }
|
||||
)?.response;
|
||||
const serverMsg = resp?.data?.message;
|
||||
if (serverMsg && serverMsg.trim().length > 0) {
|
||||
// The server already explains the cause (e.g. provider 404, bad format,
|
||||
// STT not configured) — show it verbatim.
|
||||
return serverMsg;
|
||||
}
|
||||
if (resp?.status === 503 || resp?.status === 403) {
|
||||
return t("Voice dictation is not configured");
|
||||
}
|
||||
return `${t("Transcription failed")}: ${(err as { message?: string })?.message ?? String(err)}`;
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// Handle one ended speech segment: encode to WAV and transcribe. Results are
|
||||
// buffered by seq and flushed in order. A single failed segment does NOT kill
|
||||
// the session: log + one notification, then advance past that seq so later
|
||||
@@ -204,10 +193,14 @@ export function useStreamingDictation(
|
||||
if (epoch !== epochRef.current) return;
|
||||
// Log the full error for diagnosis (status + body + stack).
|
||||
console.error("[dictation] segment transcription failed", err);
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: transcriptionErrorMessage(err),
|
||||
const { code, serverMessage } = classifyTranscriptionError(err);
|
||||
const detail = (err as { message?: string })?.message ?? String(err);
|
||||
const message = dictationErrorMessage(code, t, {
|
||||
serverMessage,
|
||||
detail,
|
||||
});
|
||||
notifications.show({ color: "red", message });
|
||||
setErrorMessage(message);
|
||||
// Skip this seq so later segments can still flush in order.
|
||||
if (nextEmitSeqRef.current === seq) {
|
||||
nextEmitSeqRef.current += 1;
|
||||
@@ -226,7 +219,7 @@ export function useStreamingDictation(
|
||||
}
|
||||
});
|
||||
},
|
||||
[drainResults, transcriptionErrorMessage],
|
||||
[drainResults, t],
|
||||
);
|
||||
|
||||
const start = useCallback(async (): Promise<void> => {
|
||||
@@ -236,6 +229,8 @@ export function useStreamingDictation(
|
||||
if (startingRef.current || vadRef.current || activeRef.current) return;
|
||||
if (status !== "idle") return;
|
||||
startingRef.current = true;
|
||||
// Clear any stale error from a previous attempt.
|
||||
setErrorMessage(null);
|
||||
|
||||
// Notify the caller right when dictation begins (before any async work) so the
|
||||
// editor can snapshot the caret position.
|
||||
@@ -354,10 +349,9 @@ export function useStreamingDictation(
|
||||
// actually runs.)
|
||||
console.error("[dictation] VAD init failed", err);
|
||||
const detail = (err as { message?: string })?.message ?? String(err);
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: `${t("Could not start recording")}: ${detail}`,
|
||||
});
|
||||
const message = dictationErrorMessage("vad-init-failed", t, { detail });
|
||||
notifications.show({ color: "red", message });
|
||||
setErrorMessage(message);
|
||||
// Defensive: if MicVAD.new partially succeeded before throwing, make sure we
|
||||
// don't leak it.
|
||||
destroyVad();
|
||||
@@ -379,19 +373,11 @@ export function useStreamingDictation(
|
||||
} catch (err) {
|
||||
// Always log the full error for diagnosis (name, message, stack).
|
||||
console.error("[dictation] VAD.start failed", err);
|
||||
const name = (err as { name?: string })?.name;
|
||||
const detail = (err as { message?: string })?.message ?? String(err);
|
||||
let message: string;
|
||||
if (name === "NotAllowedError" || name === "SecurityError") {
|
||||
message = t("Microphone access denied");
|
||||
} else if (name === "NotFoundError" || name === "OverconstrainedError") {
|
||||
message = t("No microphone found");
|
||||
} else if (name === "NotReadableError" || name === "AbortError") {
|
||||
message = t("Microphone is unavailable or already in use");
|
||||
} else {
|
||||
message = `${t("Could not start recording")}: ${detail}`;
|
||||
}
|
||||
const code = classifyGetUserMediaError(err);
|
||||
const message = dictationErrorMessage(code, t, { detail });
|
||||
notifications.show({ color: "red", message });
|
||||
setErrorMessage(message);
|
||||
activeRef.current = false;
|
||||
destroyVad();
|
||||
setStatus("idle");
|
||||
@@ -470,5 +456,5 @@ export function useStreamingDictation(
|
||||
};
|
||||
}, [clearTimer, destroyVad]);
|
||||
|
||||
return { status, start, stop, cancel, audioLevel };
|
||||
return { status, start, stop, cancel, audioLevel, errorMessage };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { atom } from "jotai";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
||||
|
||||
export const pageEditorAtom = atom<Editor | null>(null);
|
||||
|
||||
@@ -15,3 +16,15 @@ export const showLinkMenuAtom = atom(false);
|
||||
// Current page's edit mode — initialized from the user's saved preference on
|
||||
// first load, can be toggled locally without persisting to the server.
|
||||
export const currentPageEditModeAtom = atom<PageEditMode>(PageEditMode.Edit);
|
||||
|
||||
// Whether the dictation mic can start, and (when it can't) the cause-specific
|
||||
// reason the mic button surfaces as a tooltip. Published by the page editor,
|
||||
// consumed by DictationGroup -> MicButton.
|
||||
export type DictationAvailability = {
|
||||
isEditable: boolean;
|
||||
reason: DictationUnavailableReason | null;
|
||||
};
|
||||
export const dictationAvailabilityAtom = atom<DictationAvailability>({
|
||||
isEditable: false,
|
||||
reason: null,
|
||||
});
|
||||
|
||||
+32
-47
@@ -1,36 +1,15 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { useEffect, useState } from "react";
|
||||
import { render, act } from "@testing-library/react";
|
||||
import { Provider, createStore } from "jotai";
|
||||
import { dictationAvailabilityAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
|
||||
// Regression test for #311: on a page the user can edit, the byline mic stayed
|
||||
// stuck disabled until an unrelated re-render happened, because DictationGroup
|
||||
// read the non-reactive field `editor.isEditable` directly. The fix reads it via
|
||||
// `useEditorState`, which subscribes to the editor's own events.
|
||||
//
|
||||
// The mock below mirrors the real `useEditorState` contract: it runs the
|
||||
// selector, and re-runs it (re-rendering the consumer) whenever the editor emits
|
||||
// an event. This is what makes the test faithful — with the pre-fix code
|
||||
// (`disabled={!editor.isEditable}`) DictationGroup never subscribes, so emitting
|
||||
// an event would NOT re-render and the mic would stay disabled.
|
||||
vi.mock("@tiptap/react", () => ({
|
||||
useEditorState: ({ editor, selector }: any) => {
|
||||
const [value, setValue] = useState(() => selector({ editor }));
|
||||
useEffect(() => {
|
||||
const handler = () => setValue(selector({ editor }));
|
||||
editor.on("update", handler);
|
||||
return () => editor.off("update", handler);
|
||||
}, [editor, selector]);
|
||||
return value;
|
||||
},
|
||||
}));
|
||||
|
||||
// The mic only cares about the workspace's streaming flag; return a stable stub.
|
||||
vi.mock("jotai", () => ({
|
||||
useAtomValue: () => ({ settings: { ai: { dictationStreaming: false } } }),
|
||||
}));
|
||||
vi.mock("@/features/user/atoms/current-user-atom.ts", () => ({
|
||||
workspaceAtom: {},
|
||||
}));
|
||||
// Regression test for the byline mic staying stuck disabled (#311 / #309): on a
|
||||
// page the user can edit, the mic must un-grey once the body becomes editable.
|
||||
// #311 first fixed this by reading `editor.isEditable` via `useEditorState`; #309
|
||||
// superseded that with a reactive `dictationAvailabilityAtom` that page-editor
|
||||
// publishes (carrying both the editable gate AND the unavailable reason). The mic
|
||||
// now gates on `dictationAvailability.isEditable`, so a change to that atom must
|
||||
// re-render the group and flip the disabled state (jotai drives the subscription).
|
||||
|
||||
// Detectable stand-in that surfaces the `disabled` prop the component computes.
|
||||
vi.mock("@/features/dictation/components/mic-button", () => ({
|
||||
@@ -41,33 +20,39 @@ vi.mock("@/features/dictation/components/mic-button", () => ({
|
||||
|
||||
import { DictationGroup } from "./dictation-group";
|
||||
|
||||
// Minimal editor stand-in: a mutable `isEditable` field plus a tiny event
|
||||
// emitter, matching the surface DictationGroup + the mocked useEditorState use.
|
||||
function makeFakeEditor(isEditable: boolean) {
|
||||
const listeners = new Set<() => void>();
|
||||
// Minimal editor stand-in matching the surface DictationGroup uses (handleStart /
|
||||
// handleText). The disabled gate no longer reads this — it reads the atom.
|
||||
function makeFakeEditor() {
|
||||
return {
|
||||
isEditable,
|
||||
isEditable: false,
|
||||
isDestroyed: false,
|
||||
state: { selection: { from: 0, to: 0 }, doc: { content: { size: 0 } } },
|
||||
on: (_event: string, cb: () => void) => listeners.add(cb),
|
||||
off: (_event: string, cb: () => void) => listeners.delete(cb),
|
||||
emit: () => listeners.forEach((cb) => cb()),
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("DictationGroup editable reactivity (#311)", () => {
|
||||
it("re-enables the mic when the editor flips isEditable false -> true", () => {
|
||||
const editor = makeFakeEditor(false);
|
||||
const { getByTestId } = render(<DictationGroup editor={editor} />);
|
||||
describe("DictationGroup editable reactivity (#309 atom / #311)", () => {
|
||||
it("re-enables the mic when dictationAvailability flips isEditable false -> true", () => {
|
||||
const editor = makeFakeEditor();
|
||||
const store = createStore();
|
||||
// Pre-sync: page editor publishes not-editable (with a reason).
|
||||
store.set(dictationAvailabilityAtom, {
|
||||
isEditable: false,
|
||||
reason: "connecting",
|
||||
});
|
||||
|
||||
// Pre-sync: not editable yet, so the mic is disabled (preserves #218 intent).
|
||||
const { getByTestId } = render(
|
||||
<Provider store={store}>
|
||||
<DictationGroup editor={editor} />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
// Not editable yet -> disabled (preserves the #218 pre-sync intent).
|
||||
expect(getByTestId("mic").hasAttribute("disabled")).toBe(true);
|
||||
|
||||
// Collab sync flips the editor editable via editor.setEditable(true), which
|
||||
// mutates the field and emits — the mic must react and enable itself.
|
||||
// Collab sync -> page editor republishes editable; the atom change must
|
||||
// re-render the group and enable the mic.
|
||||
act(() => {
|
||||
editor.isEditable = true;
|
||||
editor.emit();
|
||||
store.set(dictationAvailabilityAtom, { isEditable: true, reason: null });
|
||||
});
|
||||
|
||||
expect(getByTestId("mic").hasAttribute("disabled")).toBe(false);
|
||||
|
||||
+6
-10
@@ -1,7 +1,8 @@
|
||||
import { FC, useRef } from "react";
|
||||
import { Editor, useEditorState } from "@tiptap/react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { dictationAvailabilityAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { MicButton } from "@/features/dictation/components/mic-button";
|
||||
|
||||
interface Props {
|
||||
@@ -16,20 +17,14 @@ export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const streamingDictation =
|
||||
workspace?.settings?.ai?.dictationStreaming === true;
|
||||
// Cause-specific reason the mic is unavailable (published by the page editor).
|
||||
const dictationAvailability = useAtomValue(dictationAvailabilityAtom);
|
||||
// Caret snapshot taken when dictation starts (where the first segment lands).
|
||||
const rangeRef = useRef<{ from: number; to: number } | null>(null);
|
||||
// Running insertion point: after each inserted segment we remember the caret
|
||||
// end so the NEXT segment appends right after it, contiguously, regardless of
|
||||
// where the user's caret currently is. Null until the first segment lands.
|
||||
const insertPosRef = useRef<number | null>(null);
|
||||
// editor.isEditable is a mutable, non-reactive field — read it via
|
||||
// useEditorState so the mic re-enables when the body flips to editable after
|
||||
// collab sync (otherwise it stays stuck disabled). Mirrors the body's own
|
||||
// reactive read.
|
||||
const isEditable = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => ctx.editor?.isEditable ?? false,
|
||||
});
|
||||
|
||||
const handleStart = () => {
|
||||
const { from, to } = editor.state.selection;
|
||||
@@ -88,7 +83,8 @@ export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
|
||||
streaming={streamingDictation}
|
||||
onStart={handleStart}
|
||||
onText={handleText}
|
||||
disabled={!isEditable}
|
||||
disabled={!dictationAvailability.isEditable}
|
||||
unavailableReason={dictationAvailability.reason ?? undefined}
|
||||
color={color}
|
||||
iconSize={iconSize}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { WebSocketStatus } from "@hocuspocus/provider";
|
||||
import { isCollabSynced, isBodyEditable } from "./editor-sync-state";
|
||||
import {
|
||||
isCollabSynced,
|
||||
isBodyEditable,
|
||||
computeDictationAvailability,
|
||||
} from "./editor-sync-state";
|
||||
|
||||
describe("isCollabSynced", () => {
|
||||
it("is true only when Connected and synced", () => {
|
||||
@@ -30,3 +34,77 @@ describe("isBodyEditable (pre-sync data-loss gate, #218)", () => {
|
||||
expect(isBodyEditable({ ...base, inEditMode: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeDictationAvailability (mic reason precedence, #309)", () => {
|
||||
const base = {
|
||||
editable: true,
|
||||
inEditMode: true,
|
||||
showStatic: false,
|
||||
isDisconnected: false,
|
||||
};
|
||||
|
||||
it("is available with no reason once synced (showStatic false)", () => {
|
||||
expect(computeDictationAvailability(base)).toEqual({
|
||||
isEditable: true,
|
||||
reason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports 'offline' during pre-sync while disconnected", () => {
|
||||
expect(
|
||||
computeDictationAvailability({
|
||||
...base,
|
||||
showStatic: true,
|
||||
isDisconnected: true,
|
||||
}),
|
||||
).toEqual({ isEditable: false, reason: "offline" });
|
||||
});
|
||||
|
||||
it("reports 'connecting' during pre-sync while still connecting", () => {
|
||||
expect(
|
||||
computeDictationAvailability({
|
||||
...base,
|
||||
showStatic: true,
|
||||
isDisconnected: false,
|
||||
}),
|
||||
).toEqual({ isEditable: false, reason: "connecting" });
|
||||
});
|
||||
|
||||
it("reports 'read-only' without edit permission", () => {
|
||||
expect(
|
||||
computeDictationAvailability({ ...base, editable: false }),
|
||||
).toEqual({ isEditable: false, reason: "read-only" });
|
||||
});
|
||||
|
||||
it("reports 'read-only' when not in edit mode", () => {
|
||||
expect(
|
||||
computeDictationAvailability({ ...base, inEditMode: false }),
|
||||
).toEqual({ isEditable: false, reason: "read-only" });
|
||||
});
|
||||
|
||||
// Lack of edit permission takes precedence over the pre-sync reason: a
|
||||
// read-only viewer who is ALSO inside the pre-sync window (showStatic) must
|
||||
// still read "read-only", never "offline"/"connecting". This pins the
|
||||
// `opts.editable &&` guard on the pre-sync branch.
|
||||
it("prefers 'read-only' over pre-sync when a read-only viewer is disconnected", () => {
|
||||
expect(
|
||||
computeDictationAvailability({
|
||||
editable: false,
|
||||
inEditMode: true,
|
||||
showStatic: true,
|
||||
isDisconnected: true,
|
||||
}),
|
||||
).toEqual({ isEditable: false, reason: "read-only" });
|
||||
});
|
||||
|
||||
it("prefers 'read-only' over pre-sync when a read-only viewer is still connecting", () => {
|
||||
expect(
|
||||
computeDictationAvailability({
|
||||
editable: false,
|
||||
inEditMode: true,
|
||||
showStatic: true,
|
||||
isDisconnected: false,
|
||||
}),
|
||||
).toEqual({ isEditable: false, reason: "read-only" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WebSocketStatus } from "@hocuspocus/provider";
|
||||
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
||||
|
||||
/**
|
||||
* The collab document is usable only once the provider is Connected AND has
|
||||
@@ -30,3 +31,32 @@ export function isBodyEditable(opts: {
|
||||
}): boolean {
|
||||
return opts.editable && opts.inEditMode && !opts.showStatic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether dictation can start and, when it can't, the cause-specific reason the
|
||||
* mic button surfaces. Derives editability from `isBodyEditable` (the single,
|
||||
* tested gate) so the published `isEditable` can never diverge from the actual
|
||||
* body-editable state and make the tooltip lie (#309).
|
||||
*
|
||||
* `isDisconnected` is the caller's own boolean (collab connection is in the
|
||||
* Disconnected state), passed in so this module stays free of the collab enum.
|
||||
*/
|
||||
export function computeDictationAvailability(opts: {
|
||||
editable: boolean;
|
||||
inEditMode: boolean;
|
||||
showStatic: boolean;
|
||||
isDisconnected: boolean;
|
||||
}): { isEditable: boolean; reason: DictationUnavailableReason | null } {
|
||||
const isEditable = isBodyEditable({
|
||||
editable: opts.editable,
|
||||
inEditMode: opts.inEditMode,
|
||||
showStatic: opts.showStatic,
|
||||
});
|
||||
if (isEditable) return { isEditable, reason: null };
|
||||
// Permitted to edit and in edit mode but not yet synced (showStatic) → pre-sync.
|
||||
if (opts.editable && opts.inEditMode && opts.showStatic) {
|
||||
return { isEditable, reason: opts.isDisconnected ? "offline" : "connecting" };
|
||||
}
|
||||
// No edit permission or not in edit mode.
|
||||
return { isEditable, reason: "read-only" };
|
||||
}
|
||||
|
||||
@@ -27,11 +27,12 @@ import {
|
||||
collabExtensions,
|
||||
mainExtensions,
|
||||
} from "@/features/editor/extensions/extensions";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import {
|
||||
currentPageEditModeAtom,
|
||||
dictationAvailabilityAtom,
|
||||
pageEditorAtom,
|
||||
yjsConnectionStatusAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
@@ -88,6 +89,7 @@ import { PageEmbedAncestryProvider } from "@/features/editor/components/page-emb
|
||||
import PageEmbedPicker from "@/features/editor/components/page-embed/page-embed-picker";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
computeDictationAvailability,
|
||||
isBodyEditable,
|
||||
isCollabSynced,
|
||||
} from "@/features/editor/editor-sync-state";
|
||||
@@ -139,6 +141,7 @@ export default function PageEditor({
|
||||
const { pageSlug } = useParams();
|
||||
const slugId = extractPageSlugId(pageSlug);
|
||||
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
||||
const setDictationAvailability = useSetAtom(dictationAvailabilityAtom);
|
||||
const canScroll = useCallback(
|
||||
() => Boolean(isComponentMounted.current && editorRef.current),
|
||||
[isComponentMounted],
|
||||
@@ -488,6 +491,26 @@ export default function PageEditor({
|
||||
);
|
||||
}, [currentPageEditMode, editor, editable, showStatic]);
|
||||
|
||||
// Publish whether dictation can start and, if not, the cause-specific reason
|
||||
// the mic button surfaces. Recomputed on the same signals that drive body
|
||||
// editability so the tooltip never lies about the current state.
|
||||
useEffect(() => {
|
||||
setDictationAvailability(
|
||||
computeDictationAvailability({
|
||||
editable,
|
||||
inEditMode: currentPageEditMode === PageEditMode.Edit,
|
||||
showStatic,
|
||||
isDisconnected: yjsConnectionStatus === WebSocketStatus.Disconnected,
|
||||
}),
|
||||
);
|
||||
}, [
|
||||
editable,
|
||||
currentPageEditMode,
|
||||
showStatic,
|
||||
yjsConnectionStatus,
|
||||
setDictationAvailability,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!hasConnectedOnceRef.current &&
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Button, Group, Paper, Text } from "@mantine/core";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconClockHour4, IconTrash } from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
@@ -70,7 +77,14 @@ export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
|
||||
return (
|
||||
<Paper radius="sm" mb="md" px="md" py="xs" bg="orange.0">
|
||||
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||
<Group gap="xs" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
{/* A non-zero flex-basis lets the outer wrap="wrap" drop the buttons to
|
||||
their own row on narrow screens; flex:1 (basis 0) never wraps and
|
||||
instead crushes the text into a one-word-per-line ladder. */}
|
||||
<Group
|
||||
gap="xs"
|
||||
wrap="nowrap"
|
||||
style={{ flex: "1 1 16rem", minWidth: 0 }}
|
||||
>
|
||||
<IconClockHour4
|
||||
size={18}
|
||||
stroke={1.5}
|
||||
@@ -87,28 +101,58 @@ export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
|
||||
</Text>
|
||||
</Group>
|
||||
{canEdit && (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={handleTrashNow}
|
||||
loading={isDeleting}
|
||||
>
|
||||
{t("Move to trash")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<IconClockHour4 size={16} />}
|
||||
onClick={handleMakePermanent}
|
||||
loading={toggleTemporary.isPending}
|
||||
>
|
||||
{t("Make permanent")}
|
||||
</Button>
|
||||
</Group>
|
||||
<>
|
||||
{/* Desktop: full labeled buttons. */}
|
||||
<Group gap="xs" wrap="nowrap" visibleFrom="sm">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={handleTrashNow}
|
||||
loading={isDeleting}
|
||||
>
|
||||
{t("Move to trash")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<IconClockHour4 size={16} />}
|
||||
onClick={handleMakePermanent}
|
||||
loading={toggleTemporary.isPending}
|
||||
>
|
||||
{t("Make permanent")}
|
||||
</Button>
|
||||
</Group>
|
||||
{/* Mobile: icon-only actions so they never overflow the narrow row. */}
|
||||
<Group gap="xs" wrap="nowrap" hiddenFrom="sm">
|
||||
<Tooltip label={t("Move to trash")} withArrow>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={handleTrashNow}
|
||||
loading={isDeleting}
|
||||
aria-label={t("Move to trash")}
|
||||
>
|
||||
<IconTrash size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("Make permanent")} withArrow>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="light"
|
||||
color="orange"
|
||||
onClick={handleMakePermanent}
|
||||
loading={toggleTemporary.isPending}
|
||||
aria-label={t("Make permanent")}
|
||||
>
|
||||
<IconClockHour4 size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
PALETTE,
|
||||
avatarStyle,
|
||||
avatarBackgroundCss,
|
||||
normalizeName,
|
||||
minPairwiseDistance,
|
||||
relativeLuminance,
|
||||
contrastRatio,
|
||||
oklchToSrgb,
|
||||
isInGamut,
|
||||
} from "./avatar-palette";
|
||||
|
||||
/** Parse "#rrggbb" into sRGB components on the 0..1 scale relativeLuminance expects. */
|
||||
function hexToRgb01(hex: string): [number, number, number] {
|
||||
return [
|
||||
parseInt(hex.slice(1, 3), 16) / 255,
|
||||
parseInt(hex.slice(3, 5), 16) / 255,
|
||||
parseInt(hex.slice(5, 7), 16) / 255,
|
||||
];
|
||||
}
|
||||
|
||||
describe("avatar-palette validation", () => {
|
||||
it("palette colors stay distinguishable", () => {
|
||||
// 0.06 in OKLab is ~4-5 JNDs — safely distinct at avatar size. If a future
|
||||
// RINGS tweak drops this, "almost identical" colors would reappear.
|
||||
expect(minPairwiseDistance().distance).toBeGreaterThanOrEqual(0.06);
|
||||
expect(PALETTE.length).toBe(20);
|
||||
});
|
||||
|
||||
it("every palette entry is WCAG-readable and in sRGB gamut", () => {
|
||||
// white text = luminance 1, black text = luminance 0 (per buildPalette).
|
||||
const textLum = { white: 1, black: 0 } as const;
|
||||
for (const entry of PALETTE) {
|
||||
expect(entry.hex).toMatch(/^#[0-9a-f]{6}$/);
|
||||
|
||||
// (a) The chosen text color really clears the code's 3:1 threshold on the
|
||||
// actual background hex — recomputed independently from the hex, not from
|
||||
// the build-time luminance. A slot that picked the wrong text (or a color
|
||||
// too dim for either text) would fail here.
|
||||
const hexLum = relativeLuminance(hexToRgb01(entry.hex));
|
||||
const chosen = contrastRatio(textLum[entry.text], hexLum);
|
||||
expect(chosen).toBeGreaterThanOrEqual(3);
|
||||
// buildPalette prefers white and only falls back to black when white
|
||||
// fails 3:1. Mirror that decision: black is used *only* when white would
|
||||
// not clear the threshold — so a mis-assigned "black" on a dark color
|
||||
// (where white was fine) fails here.
|
||||
if (entry.text === "black") {
|
||||
expect(contrastRatio(textLum.white, hexLum)).toBeLessThan(3);
|
||||
}
|
||||
|
||||
// (b) The entry's OKLCH is inside the sRGB gamut after chroma clamping;
|
||||
// an out-of-gamut slot (e.g. un-clamped chroma) would produce components
|
||||
// outside [0,1] and fail here.
|
||||
expect(isInGamut(oklchToSrgb(entry.L, entry.C, entry.h))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("avatarStyle", () => {
|
||||
it("name-to-avatar mapping is frozen (golden values)", () => {
|
||||
// Golden slice: if this breaks, all existing avatars change — make sure
|
||||
// that is intentional (a config change in avatar-palette.ts).
|
||||
const s = avatarStyle("Backend Developer");
|
||||
expect([s.bg, s.bg2, s.angleDeg]).toEqual(["#a55795", "#90355e", 150]);
|
||||
expect(s.text).toBe("white");
|
||||
});
|
||||
|
||||
it("is deterministic and normalizes the name", () => {
|
||||
expect(avatarStyle("Researcher")).toEqual(avatarStyle("Researcher"));
|
||||
// Casing, surrounding and repeated whitespace must not change the avatar.
|
||||
expect(avatarStyle(" RESEARCHER ")).toEqual(avatarStyle("researcher"));
|
||||
expect(avatarStyle("Backend Developer")).toEqual(
|
||||
avatarStyle("backend developer"),
|
||||
);
|
||||
expect(normalizeName(" PM ")).toBe("pm");
|
||||
});
|
||||
|
||||
it("returns a valid base color, angle and matching text", () => {
|
||||
const s = avatarStyle("Нарратор");
|
||||
const idx = PALETTE.findIndex((e) => e.hex === s.bg);
|
||||
expect(idx).toBe(s.paletteIndex);
|
||||
expect(idx).toBeGreaterThanOrEqual(0); // bg is a palette entry
|
||||
// Text color comes from the chosen palette entry.
|
||||
expect(s.text).toBe(PALETTE[idx].text);
|
||||
// Split angle is one of the SPLIT_ANGLE_STEPS (24) directions → multiples of 15.
|
||||
expect(s.angleDeg % 15).toBe(0);
|
||||
expect(s.angleDeg).toBeGreaterThanOrEqual(0);
|
||||
expect(s.angleDeg).toBeLessThan(360);
|
||||
});
|
||||
|
||||
it("distinguishes the agents that used to collide as violet", () => {
|
||||
// "Структурный редактор" and "Фактчекер" looked identically violet before.
|
||||
expect(avatarStyle("Структурный редактор")).not.toEqual(
|
||||
avatarStyle("Фактчекер"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("avatarBackgroundCss", () => {
|
||||
it("renders a two-stop gradient with a soft boundary", () => {
|
||||
const s = avatarStyle("Backend Developer");
|
||||
expect(avatarBackgroundCss(s)).toBe(
|
||||
"linear-gradient(150deg, #a55795 42%, #90355e 58%)",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Deterministic avatar backgrounds for agent roles.
|
||||
*
|
||||
* The palette is generated from scratch at module load in OKLCH (a perceptually
|
||||
* uniform color space), so every value below is tunable: change the ring
|
||||
* configuration or the partner shifts and the whole palette regenerates.
|
||||
*
|
||||
* Pipeline: name -> normalize -> cyrb53 hash -> split into independent fields:
|
||||
* - base color index (one of the validated palette colors)
|
||||
* - partner hue shift: analogous 20..45deg (either side), complementary 180deg,
|
||||
* or triadic +/-120deg — classic color-wheel schemes; partner is also darker
|
||||
* - split angle (SPLIT_ANGLE_STEPS directions, soft boundary)
|
||||
* The same name always yields the same avatar, on any platform, forever.
|
||||
*/
|
||||
|
||||
// ------------------------- Tunable configuration -------------------------
|
||||
|
||||
export interface RingConfig {
|
||||
/** OKLCH lightness, 0..1 */
|
||||
L: number;
|
||||
/** OKLCH chroma target; clamped down per-hue to fit the sRGB gamut */
|
||||
C: number;
|
||||
/** Hue of the first color in the ring, degrees */
|
||||
hueStart: number;
|
||||
/** Number of evenly spaced hues in the ring */
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two lightness rings. 12 light + 8 dark = 20 base colors with a validated
|
||||
* min pairwise deltaE-OK of ~0.066 (clearly distinguishable at avatar size).
|
||||
* Don't add more hues per ring without re-checking minPairwiseDistance():
|
||||
* beyond ~20-24 colors humans stop telling them apart reliably.
|
||||
*/
|
||||
const RINGS: readonly RingConfig[] = [
|
||||
{ L: 0.70, C: 0.14, hueStart: 15, count: 12 }, // light ring
|
||||
{ L: 0.57, C: 0.13, hueStart: 20, count: 8 }, // darker ring
|
||||
];
|
||||
|
||||
/** Partner color: lightness shifted by this much (negative = darker) */
|
||||
const PARTNER_L_SHIFT = -0.10;
|
||||
/** Analogous scheme: hue shift magnitude range, degrees (inclusive, 5-deg steps) */
|
||||
const ANALOG_MIN_SHIFT = 20;
|
||||
const ANALOG_SHIFT_STEP = 5;
|
||||
const ANALOG_SHIFT_STEPS = 6; // 20, 25, 30, 35, 40, 45
|
||||
/** Complementary scheme: fixed hue shift, degrees */
|
||||
const COMPLEMENTARY_SHIFT = 180;
|
||||
/** Triadic scheme: fixed hue shift magnitude, degrees (either side) */
|
||||
const TRIADIC_SHIFT = 120;
|
||||
/** Number of split directions (24 -> 15deg per step) */
|
||||
const SPLIT_ANGLE_STEPS = 24;
|
||||
/** Position of the color boundary, percent of the gradient axis */
|
||||
const SPLIT_PERCENT = 50;
|
||||
/** Width of the soft transition zone around the boundary, percent (0 = hard edge) */
|
||||
const SPLIT_SOFTNESS = 16;
|
||||
|
||||
// ------------------------- OKLCH -> sRGB math -------------------------
|
||||
// Matrices from Bjorn Ottosson's OKLab reference implementation.
|
||||
|
||||
function oklabToLinearSrgb(L: number, a: number, b: number): [number, number, number] {
|
||||
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
||||
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
||||
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
||||
const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3;
|
||||
return [
|
||||
+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
||||
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
||||
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
|
||||
];
|
||||
}
|
||||
|
||||
function gammaEncode(c: number): number {
|
||||
return c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055;
|
||||
}
|
||||
|
||||
export function oklchToSrgb(L: number, C: number, hDeg: number): [number, number, number] {
|
||||
const h = (hDeg * Math.PI) / 180;
|
||||
const [r, g, b] = oklabToLinearSrgb(L, C * Math.cos(h), C * Math.sin(h));
|
||||
return [gammaEncode(r), gammaEncode(g), gammaEncode(b)];
|
||||
}
|
||||
|
||||
export function isInGamut(rgb: readonly number[]): boolean {
|
||||
return rgb.every((c) => c >= -1e-6 && c <= 1 + 1e-6);
|
||||
}
|
||||
|
||||
/** Binary-search the max chroma <= C that fits into the sRGB gamut. */
|
||||
function clampChroma(L: number, C: number, hDeg: number): number {
|
||||
if (isInGamut(oklchToSrgb(L, C, hDeg))) return C;
|
||||
let lo = 0, hi = C;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const mid = (lo + hi) / 2;
|
||||
if (isInGamut(oklchToSrgb(L, mid, hDeg))) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
function toHex(rgb: readonly number[]): string {
|
||||
return (
|
||||
"#" +
|
||||
rgb
|
||||
.map((c) => Math.round(Math.min(1, Math.max(0, c)) * 255).toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
);
|
||||
}
|
||||
|
||||
/** WCAG relative luminance of an sRGB color (components 0..1). */
|
||||
export function relativeLuminance(rgb: readonly number[]): number {
|
||||
const lin = rgb.map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
||||
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
|
||||
}
|
||||
|
||||
export function contrastRatio(l1: number, l2: number): number {
|
||||
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||
}
|
||||
|
||||
// ------------------------- Palette generation -------------------------
|
||||
|
||||
export interface PaletteEntry {
|
||||
/** Base background color */
|
||||
hex: string;
|
||||
/** OKLCH coordinates of the base color (used to derive partner colors) */
|
||||
L: number;
|
||||
C: number;
|
||||
h: number;
|
||||
/** Text/icon color with the best WCAG contrast on the base color */
|
||||
text: "white" | "black";
|
||||
/** OKLab coordinates of the base color (kept for validation) */
|
||||
lab: readonly [number, number, number];
|
||||
}
|
||||
|
||||
function buildPalette(): PaletteEntry[] {
|
||||
const entries: PaletteEntry[] = [];
|
||||
for (const ring of RINGS) {
|
||||
const step = 360 / ring.count;
|
||||
for (let i = 0; i < ring.count; i++) {
|
||||
const h = (ring.hueStart + i * step) % 360;
|
||||
const C = clampChroma(ring.L, ring.C, h);
|
||||
const rgb = oklchToSrgb(ring.L, C, h);
|
||||
const lum = relativeLuminance(rgb);
|
||||
entries.push({
|
||||
hex: toHex(rgb),
|
||||
L: ring.L,
|
||||
C,
|
||||
h,
|
||||
// White text needs >= 3:1 contrast; otherwise fall back to black.
|
||||
text: contrastRatio(lum, 1) >= 3 ? "white" : "black",
|
||||
lab: [
|
||||
ring.L,
|
||||
C * Math.cos((h * Math.PI) / 180),
|
||||
C * Math.sin((h * Math.PI) / 180),
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Partner color for the split: base hue shifted by shiftDeg, darker by PARTNER_L_SHIFT. */
|
||||
function partnerHex(entry: PaletteEntry, shiftDeg: number): string {
|
||||
const h2 = (entry.h + shiftDeg + 360) % 360;
|
||||
const L2 = entry.L + PARTNER_L_SHIFT;
|
||||
return toHex(oklchToSrgb(L2, clampChroma(L2, entry.C, h2), h2));
|
||||
}
|
||||
|
||||
/** Generated once at module load; regenerates on every build from the config above. */
|
||||
export const PALETTE: readonly PaletteEntry[] = buildPalette();
|
||||
|
||||
// ------------------------- Name -> avatar style -------------------------
|
||||
|
||||
/** Normalize so that "PM ", "pm" and "Pm" map to the same avatar. */
|
||||
export function normalizeName(name: string): string {
|
||||
return name.normalize("NFC").trim().toLowerCase().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
/**
|
||||
* cyrb53: deterministic 53-bit string hash with good avalanche.
|
||||
* Pure JS, cross-platform — never use language built-in hashing here.
|
||||
*/
|
||||
function cyrb53(str: string, seed = 0): number {
|
||||
let h1 = 0xdeadbeef ^ seed;
|
||||
let h2 = 0x41c6ce57 ^ seed;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const ch = str.charCodeAt(i);
|
||||
h1 = Math.imul(h1 ^ ch, 2654435761);
|
||||
h2 = Math.imul(h2 ^ ch, 1597334677);
|
||||
}
|
||||
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
||||
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
||||
return 4294967296 * (2097151 & h2) + (h1 >>> 0);
|
||||
}
|
||||
|
||||
export interface AvatarStyle {
|
||||
/** Index of the base color in PALETTE */
|
||||
paletteIndex: number;
|
||||
/** Base color hex */
|
||||
bg: string;
|
||||
/** Second color hex (split partner) */
|
||||
bg2: string;
|
||||
/** Signed hue shift of the partner, degrees (e.g. -35, +45, 180, -120) */
|
||||
hueShift: number;
|
||||
/** Direction of the split, degrees */
|
||||
angleDeg: number;
|
||||
/** Text/icon color for the base color */
|
||||
text: "white" | "black";
|
||||
}
|
||||
|
||||
/** Pure function: the same (normalized) name always returns the same style. */
|
||||
export function avatarStyle(agentName: string): AvatarStyle {
|
||||
const h = cyrb53(normalizeName(agentName));
|
||||
// Slice the hash into independent fields, like digits of a number:
|
||||
const paletteIndex = h % PALETTE.length;
|
||||
let rest = Math.floor(h / PALETTE.length);
|
||||
const angleDeg = (rest % SPLIT_ANGLE_STEPS) * (360 / SPLIT_ANGLE_STEPS);
|
||||
rest = Math.floor(rest / SPLIT_ANGLE_STEPS);
|
||||
// Scheme: 0,1 -> analogous (minus/plus); 2 -> complementary; 3 -> triadic
|
||||
const scheme = rest % 4;
|
||||
rest = Math.floor(rest / 4);
|
||||
let hueShift: number;
|
||||
if (scheme === 2) {
|
||||
hueShift = COMPLEMENTARY_SHIFT;
|
||||
} else if (scheme === 3) {
|
||||
hueShift = rest % 2 ? TRIADIC_SHIFT : -TRIADIC_SHIFT;
|
||||
} else {
|
||||
const magnitude = ANALOG_MIN_SHIFT + (rest % ANALOG_SHIFT_STEPS) * ANALOG_SHIFT_STEP;
|
||||
hueShift = scheme === 0 ? -magnitude : magnitude;
|
||||
}
|
||||
const entry = PALETTE[paletteIndex];
|
||||
return {
|
||||
paletteIndex,
|
||||
bg: entry.hex,
|
||||
bg2: partnerHex(entry, hueShift),
|
||||
hueShift,
|
||||
angleDeg,
|
||||
text: entry.text,
|
||||
};
|
||||
}
|
||||
|
||||
/** CSS background value: two colors with a slightly blurred boundary. */
|
||||
export function avatarBackgroundCss(style: AvatarStyle): string {
|
||||
const from = SPLIT_PERCENT - SPLIT_SOFTNESS / 2;
|
||||
const to = SPLIT_PERCENT + SPLIT_SOFTNESS / 2;
|
||||
return `linear-gradient(${style.angleDeg}deg, ${style.bg} ${from}%, ${style.bg2} ${to}%)`;
|
||||
}
|
||||
|
||||
// ------------------------- Validation -------------------------
|
||||
|
||||
/**
|
||||
* Min pairwise deltaE-OK (euclidean distance in OKLab) between base colors.
|
||||
* Re-check after tweaking RINGS: keep it >= ~0.06 so no two palette colors
|
||||
* look alike. Intended for a unit test or a dev-time assertion.
|
||||
*/
|
||||
export function minPairwiseDistance(): { distance: number; pair: [string, string] } {
|
||||
let min = Infinity;
|
||||
let pair: [string, string] = ["", ""];
|
||||
for (let i = 0; i < PALETTE.length; i++) {
|
||||
for (let j = i + 1; j < PALETTE.length; j++) {
|
||||
const a = PALETTE[i].lab, b = PALETTE[j].lab;
|
||||
const d = Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
||||
if (d < min) {
|
||||
min = d;
|
||||
pair = [PALETTE[i].hex, PALETTE[j].hex];
|
||||
}
|
||||
}
|
||||
}
|
||||
return { distance: min, pair };
|
||||
}
|
||||
@@ -27,7 +27,11 @@ const SAFETY_FRAMEWORK = [
|
||||
'- You can read pages, comments and page history, and modify the workspace:',
|
||||
' create/rename/move pages and make structural edits (text, nodes, tables);',
|
||||
' manage page history (diff/restore); copy, import and export content; and',
|
||||
' create/resolve comments. Page edits are REVERSIBLE — they keep page',
|
||||
' create/resolve comments. An inline comment can carry a suggestedText — a',
|
||||
' proposed replacement for its selected text that the user applies with one',
|
||||
' click; when you propose a concrete rewording of a specific fragment,',
|
||||
' attach it as suggestedText instead of only describing the change. Page',
|
||||
' edits are REVERSIBLE — they keep page',
|
||||
' history and a trashed page can be restored. One exception to keep in mind:',
|
||||
' sharing a page makes it PUBLICLY accessible — do that only when the user',
|
||||
' asked.',
|
||||
|
||||
@@ -27,7 +27,7 @@ const VERSION = packageJson.version;
|
||||
// --- Modern McpServer Implementation ---
|
||||
// Editing guide surfaced to MCP clients in the initialize result so they can
|
||||
// pick the right tool by intent and avoid resending whole documents.
|
||||
const SERVER_INSTRUCTIONS = "Docmost editing guide — choose the tool by intent: fix wording/typos/numbers (text inside blocks) -> edit_page_text (no node id needed). Change ONE block (paragraph/heading/callout/table cell/etc.) structurally -> patch_node (address by attrs.id from get_page_json). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Images -> insert_image (add an image from a web URL) / replace_image (swap an existing image for one from a web URL). New page -> create_page (Markdown). Bulk/structural rewrite or nodes without an id -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Rename a page (title only) -> rename_page. Read -> get_page (Markdown, lossy) or get_page_json (lossless ProseMirror with block ids). Comments -> create_comment (always inline; requires an EXACT selection — the contiguous text to anchor/highlight on; fails rather than leaving an unanchored comment), list_comments, update_comment, resolve_comment (resolve/reopen a thread, reversible — prefer over delete to close), delete_comment, check_new_comments. Tip: read block ids via get_page_json, then use patch_node/insert_node/delete_node so you never resend the full document. " +
|
||||
const SERVER_INSTRUCTIONS = "Docmost editing guide — choose the tool by intent: fix wording/typos/numbers (text inside blocks) -> edit_page_text (no node id needed). Change ONE block (paragraph/heading/callout/table cell/etc.) structurally -> patch_node (address by attrs.id from get_page_json). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Images -> insert_image (add an image from a web URL) / replace_image (swap an existing image for one from a web URL). New page -> create_page (Markdown). Bulk/structural rewrite or nodes without an id -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Rename a page (title only) -> rename_page. Read -> get_page (Markdown, lossy) or get_page_json (lossless ProseMirror with block ids). Comments -> create_comment (always inline; requires an EXACT selection — the contiguous text to anchor/highlight on; fails rather than leaving an unanchored comment), list_comments, update_comment, resolve_comment (resolve/reopen a thread, reversible — prefer over delete to close), delete_comment, check_new_comments. Propose a concrete text fix for one-click human approval -> create_comment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Tip: read block ids via get_page_json, then use patch_node/insert_node/delete_node so you never resend the full document. " +
|
||||
"Complex/scripted rewrite (multiple coordinated edits, footnotes, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes. " +
|
||||
"Review what changed -> diff_page_versions (compare a historyId to current, or two history versions). See a page's saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). " +
|
||||
"Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown.";
|
||||
|
||||
@@ -38,7 +38,7 @@ const VERSION = packageJson.version;
|
||||
// Editing guide surfaced to MCP clients in the initialize result so they can
|
||||
// pick the right tool by intent and avoid resending whole documents.
|
||||
const SERVER_INSTRUCTIONS =
|
||||
"Docmost editing guide — choose the tool by intent: fix wording/typos/numbers (text inside blocks) -> edit_page_text (no node id needed). Change ONE block (paragraph/heading/callout/table cell/etc.) structurally -> patch_node (address by attrs.id from get_page_json). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Images -> insert_image (add an image from a web URL) / replace_image (swap an existing image for one from a web URL). New page -> create_page (Markdown). Bulk/structural rewrite or nodes without an id -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Rename a page (title only) -> rename_page. Read -> get_page (Markdown, lossy) or get_page_json (lossless ProseMirror with block ids). Comments -> create_comment (always inline; requires an EXACT selection — the contiguous text to anchor/highlight on; fails rather than leaving an unanchored comment), list_comments, update_comment, resolve_comment (resolve/reopen a thread, reversible — prefer over delete to close), delete_comment, check_new_comments. Tip: read block ids via get_page_json, then use patch_node/insert_node/delete_node so you never resend the full document. " +
|
||||
"Docmost editing guide — choose the tool by intent: fix wording/typos/numbers (text inside blocks) -> edit_page_text (no node id needed). Change ONE block (paragraph/heading/callout/table cell/etc.) structurally -> patch_node (address by attrs.id from get_page_json). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Images -> insert_image (add an image from a web URL) / replace_image (swap an existing image for one from a web URL). New page -> create_page (Markdown). Bulk/structural rewrite or nodes without an id -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Rename a page (title only) -> rename_page. Read -> get_page (Markdown, lossy) or get_page_json (lossless ProseMirror with block ids). Comments -> create_comment (always inline; requires an EXACT selection — the contiguous text to anchor/highlight on; fails rather than leaving an unanchored comment), list_comments, update_comment, resolve_comment (resolve/reopen a thread, reversible — prefer over delete to close), delete_comment, check_new_comments. Propose a concrete text fix for one-click human approval -> create_comment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Tip: read block ids via get_page_json, then use patch_node/insert_node/delete_node so you never resend the full document. " +
|
||||
"Complex/scripted rewrite (multiple coordinated edits, footnotes, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes. " +
|
||||
"Review what changed -> diff_page_versions (compare a historyId to current, or two history versions). See a page's saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). " +
|
||||
"Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown.";
|
||||
|
||||
Reference in New Issue
Block a user