Compare commits

...

2 Commits

Author SHA1 Message Date
dwelle 9448cca81d feat(editor): highlight duplicate matches in mermaid editor 2026-03-24 16:16:44 +01:00
dwelle 217c59a13a feat(editor): highlight TTD assistant responses 2026-03-24 16:14:26 +01:00
13 changed files with 394 additions and 86 deletions
@@ -3,7 +3,25 @@
$verticalBreakpoint: 861px;
.excalidraw {
--ttd-mermaid-token-keyword: #0000ff;
--ttd-mermaid-token-string: #a31515;
--ttd-mermaid-token-comment: #008000;
--ttd-mermaid-token-number: #098658;
--ttd-mermaid-token-operator: #1e1e1e;
--ttd-mermaid-token-punctuation: #1e1e1e;
--ttd-mermaid-token-variable-name: #001080;
--ttd-mermaid-token-bracket: #af00db;
&.theme--dark {
--ttd-mermaid-token-keyword: #569cd6;
--ttd-mermaid-token-string: #ce9178;
--ttd-mermaid-token-comment: #6a9955;
--ttd-mermaid-token-number: #b5cea8;
--ttd-mermaid-token-operator: #d4d4d4;
--ttd-mermaid-token-punctuation: #d4d4d4;
--ttd-mermaid-token-variable-name: #9cdcfe;
--ttd-mermaid-token-bracket: #ffd700;
.chat-message {
&--assistant {
.chat-message__content {
@@ -194,7 +212,7 @@ $verticalBreakpoint: 861px;
align-items: flex-start;
.chat-message__content {
background: var(--color-surface-low);
background: #f7f7f7;
color: var(--color-on-surface);
border-radius: var(--border-radius-md);
min-width: 6rem;
@@ -292,6 +310,51 @@ $verticalBreakpoint: 861px;
word-wrap: break-word;
}
&__text--error {
color: inherit;
}
&__text--mermaid {
overflow-x: auto;
font-weight: 400;
}
&__token {
white-space: inherit;
}
&__token--keyword {
color: var(--ttd-mermaid-token-keyword);
}
&__token--string {
color: var(--ttd-mermaid-token-string);
}
&__token--comment {
color: var(--ttd-mermaid-token-comment);
}
&__token--number {
color: var(--ttd-mermaid-token-number);
}
&__token--operator {
color: var(--ttd-mermaid-token-operator);
}
&__token--punctuation {
color: var(--ttd-mermaid-token-punctuation);
}
&__token--variableName {
color: var(--ttd-mermaid-token-variable-name);
}
&__token--bracket {
color: var(--ttd-mermaid-token-bracket);
}
&__cursor {
display: inline-block;
margin-left: 2px;
@@ -332,11 +395,13 @@ $verticalBreakpoint: 861px;
&__error {
color: var(--color-danger);
font-weight: 500;
white-space: pre-wrap;
word-wrap: break-word;
display: flex;
flex-direction: column;
gap: 0.5rem;
.chat-message__text--mermaid {
color: var(--color-on-surface);
}
}
&__error_message {
@@ -5,8 +5,49 @@ import { t } from "../../../i18n";
import { FilledButton } from "../../FilledButton";
import { TrashIcon, codeIcon, stackPushIcon, RetryIcon } from "../../icons";
import { tokenizeMermaid } from "../mermaid-highlighting";
import type { TChat, TTTDDialog } from "../types";
const isMermaidMessage = (message: TChat.ChatMessage) =>
message.contentFormat === "mermaid";
const renderMessageContent = (
message: TChat.ChatMessage,
className: string,
) => {
const content = message.content ?? "";
console.log("@", message);
if (!isMermaidMessage(message)) {
return (
<div className={className}>
{content}
{message.isGenerating && (
<span className="chat-message__cursor"></span>
)}
</div>
);
}
return (
<div className={clsx(className, "chat-message__text--mermaid")}>
{tokenizeMermaid(content).map((token, index) => (
<span
key={`${index}-${token.type ?? "text"}-${token.value}`}
className={clsx("chat-message__token", {
[`chat-message__token--${token.type}`]: token.type,
})}
>
{token.value}
</span>
))}
{message.isGenerating && <span className="chat-message__cursor"></span>}
</div>
);
};
export const ChatMessage: React.FC<{
message: TChat.ChatMessage;
onMermaidTabClick?: (message: TChat.ChatMessage) => void;
@@ -122,7 +163,14 @@ export const ChatMessage: React.FC<{
<div className="chat-message__body">
{message.error ? (
<>
<div className="chat-message__error">{message.content}</div>
<div className="chat-message__error">
{renderMessageContent(
message,
clsx("chat-message__text", {
"chat-message__text--error": !isMermaidMessage(message),
}),
)}
</div>
{message.errorType !== "parse" && (
<div className="chat-message__error_message">
Error: {message.error || t("chat.errors.generationFailed")}
@@ -132,7 +180,7 @@ export const ChatMessage: React.FC<{
<div className="chat-message__error_message">
<p>{t("chat.errors.invalidDiagram")}</p>
<div className="chat-message__error-actions">
{onMermaidTabClick && (
{onMermaidTabClick && isMermaidMessage(message) && (
<button
className="chat-message__error-link"
onClick={() => onMermaidTabClick(message)}
@@ -156,18 +204,13 @@ export const ChatMessage: React.FC<{
)}
</>
) : (
<div className="chat-message__text">
{message.content}
{message.isGenerating && (
<span className="chat-message__cursor"></span>
)}
</div>
renderMessageContent(message, "chat-message__text")
)}
</div>
</div>
{message.type === "assistant" && !message.isGenerating && (
<div className="chat-message__actions">
{!message.error && onInsertMessage && (
{!message.error && onInsertMessage && isMermaidMessage(message) && (
<button
className="chat-message__action"
onClick={() => onInsertMessage(message)}
@@ -178,7 +221,7 @@ export const ChatMessage: React.FC<{
{stackPushIcon}
</button>
)}
{onMermaidTabClick && message.content && (
{onMermaidTabClick && isMermaidMessage(message) && message.content && (
<button
className="chat-message__action"
onClick={() => onMermaidTabClick(message)}
@@ -25,6 +25,7 @@ export const TTDChatPanel = ({
onGenerate,
isGenerating,
generatedResponse,
generatedResponseFormat,
isMenuOpen,
onMenuToggle,
onMenuClose,
@@ -50,6 +51,7 @@ export const TTDChatPanel = ({
onGenerate: TTTDDialog.OnGenerate;
isGenerating: boolean;
generatedResponse: string | null | undefined;
generatedResponseFormat?: TChat.ChatMessage["contentFormat"];
isMenuOpen: boolean;
onMenuToggle: () => void;
@@ -89,7 +91,7 @@ export const TTDChatPanel = ({
});
}
if (generatedResponse) {
if (generatedResponse && generatedResponseFormat === "mermaid") {
actions.push({
action: onViewAsMermaid,
label: t("chat.viewAsMermaid"),
@@ -25,6 +25,7 @@ export const useChatAgent = () => {
{
type: "assistant",
content: "",
contentFormat: "mermaid",
isGenerating: true,
},
]),
@@ -1,13 +1,21 @@
import { useEffect, useRef } from "react";
import {
Decoration,
type DecorationSet,
EditorView,
ViewPlugin,
type ViewUpdate,
keymap,
lineNumbers,
placeholder as cmPlaceholder,
drawSelection,
} from "@codemirror/view";
import { Compartment, EditorState, type Extension } from "@codemirror/state";
import {
Compartment,
EditorState,
type Extension,
type Range,
} from "@codemirror/state";
import {
defaultKeymap,
history,
@@ -40,6 +48,13 @@ const darkTheme = EditorView.theme(
},
".cm-content": { caretColor: "#fff" },
".cm-cursor": { borderLeftColor: "#fff" },
".cm-selectionBackground": {
backgroundColor: "rgba(86, 156, 214, 0.3)",
},
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground":
{
backgroundColor: "rgba(86, 156, 214, 0.42)",
},
".cm-gutters": {
backgroundColor: "#1e1e1e",
color: "#858585",
@@ -48,6 +63,10 @@ const darkTheme = EditorView.theme(
".cm-activeLineGutter": { backgroundColor: "#2a2a2a" },
".cm-activeLine": { backgroundColor: "#2a2a2a" },
".cm-errorLine": { backgroundColor: "rgba(255, 0, 0, 0.15)" },
".cm-selectedWordMatch": {
backgroundColor: "rgba(255, 209, 102, 0.22)",
borderRadius: "2px",
},
},
{ dark: true },
);
@@ -80,6 +99,10 @@ const lightTheme = EditorView.theme({
".cm-activeLineGutter": { backgroundColor: "#e8e8e8" },
".cm-activeLine": { backgroundColor: "#e8e8e8" },
".cm-errorLine": { backgroundColor: "rgba(255, 0, 0, 0.1)" },
".cm-selectedWordMatch": {
backgroundColor: "rgba(255, 209, 102, 0.35)",
borderRadius: "2px",
},
});
const lightHighlight = HighlightStyle.define([
@@ -96,6 +119,79 @@ const lightHighlight = HighlightStyle.define([
// ---- Error line decoration ----
const errorLineDeco = Decoration.line({ class: "cm-errorLine" });
const selectedWordMatchDeco = Decoration.mark({
class: "cm-selectedWordMatch",
});
const getSelectedWordMatchText = (state: EditorState) => {
const mainSelection = state.selection.main;
if (state.selection.ranges.length !== 1 || mainSelection.empty) {
return null;
}
const selectedWord = state.wordAt(mainSelection.from);
if (
!selectedWord ||
selectedWord.from !== mainSelection.from ||
selectedWord.to !== mainSelection.to
) {
return null;
}
return state.sliceDoc(mainSelection.from, mainSelection.to);
};
const getSelectedWordMatchDecorations = (view: EditorView): DecorationSet => {
const selectedWord = getSelectedWordMatchText(view.state);
if (!selectedWord) {
return Decoration.none;
}
const selection = view.state.selection.main;
const ranges: Range<Decoration>[] = [];
const doc = view.state.doc.toString();
let searchFrom = 0;
while (searchFrom <= doc.length - selectedWord.length) {
const matchFrom = doc.indexOf(selectedWord, searchFrom);
if (matchFrom === -1) {
break;
}
const matchTo = matchFrom + selectedWord.length;
const matchWord = view.state.wordAt(matchFrom);
if (
matchWord?.from === matchFrom &&
matchWord.to === matchTo &&
(matchFrom !== selection.from || matchTo !== selection.to)
) {
ranges.push(selectedWordMatchDeco.range(matchFrom, matchTo));
}
searchFrom = matchTo;
}
return ranges.length ? Decoration.set(ranges) : Decoration.none;
};
const selectedWordMatchExtension = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = getSelectedWordMatchDecorations(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.selectionSet) {
this.decorations = getSelectedWordMatchDecorations(update.view);
}
}
},
{
decorations: (value) => value.decorations,
},
);
const getErrorLineExtension = (
errorLine: number | null | undefined,
@@ -172,6 +268,7 @@ const CodeMirrorEditor = ({
errorLineCompartmentRef.current.of([]),
mermaidLite(),
drawSelection({ drawRangeCursor: true }),
selectedWordMatchExtension,
...(placeholder ? [cmPlaceholder(placeholder)] : []),
],
}),
@@ -80,7 +80,10 @@ const TextToDiagramContent = ({
} = useChatManagement({ persistenceAdapter });
const onViewAsMermaid = () => {
if (typeof lastAssistantMessage?.content === "string") {
if (
lastAssistantMessage?.contentFormat === "mermaid" &&
typeof lastAssistantMessage.content === "string"
) {
saveMermaidDataToStorage(lastAssistantMessage.content);
setAppState({
openDialog: { name: "ttd", tab: "mermaid" },
@@ -206,6 +209,7 @@ const TextToDiagramContent = ({
onGenerate={onGenerate}
isGenerating={lastAssistantMessage?.isGenerating ?? false}
generatedResponse={lastAssistantMessage?.content}
generatedResponseFormat={lastAssistantMessage?.contentFormat}
isMenuOpen={isMenuOpen}
onMenuToggle={handleMenuToggle}
onMenuClose={handleMenuClose}
@@ -88,6 +88,7 @@ export const useTextGeneration = ({
updateAssistantContent(prev, {
isGenerating: true,
content: "",
contentFormat: "mermaid",
error: undefined,
errorType: undefined,
errorDetails: undefined,
@@ -0,0 +1,40 @@
import {
getMermaidHighlightToken,
tokenizeMermaid,
} from "./mermaid-highlighting";
describe("mermaid highlighting", () => {
it("tokenizes mermaid syntax with shared token types", () => {
const tokens = tokenizeMermaid('flowchart LR\nA["Hello"] --> B');
expect(tokens).toEqual([
{ type: "keyword", value: "flowchart" },
{ type: null, value: " " },
{ type: "keyword", value: "LR" },
{ type: null, value: "\n" },
{ type: "variableName", value: "A" },
{ type: "bracket", value: "[" },
{ type: "string", value: '"Hello"' },
{ type: "bracket", value: "]" },
{ type: null, value: " " },
{ type: "operator", value: "-->" },
{ type: null, value: " " },
{ type: "variableName", value: "B" },
]);
});
it("limits comment tokens to a single line", () => {
const tokens = tokenizeMermaid("%% comment\nflowchart TD");
expect(tokens[0]).toEqual({ type: "comment", value: "%% comment" });
expect(tokens[1]).toEqual({ type: null, value: "\n" });
expect(tokens[2]).toEqual({ type: "keyword", value: "flowchart" });
});
it("falls back to plain text for unsupported characters", () => {
expect(getMermaidHighlightToken("@node")).toEqual({
type: null,
value: "@",
});
});
});
@@ -0,0 +1,79 @@
export type MermaidHighlightTokenType =
| "bracket"
| "comment"
| "keyword"
| "number"
| "operator"
| "punctuation"
| "string"
| "variableName";
export type MermaidHighlightToken = {
type: MermaidHighlightTokenType | null;
value: string;
};
const DIAGRAM_TYPE_PATTERN =
/^(flowchart|graph|sequenceDiagram|classDiagram|stateDiagram|erDiagram|gantt|pie|mindmap|journey|gitGraph|timeline|quadrantChart|sankey|xychart)\b/i;
const DIRECTION_PATTERN = /^(TB|TD|BT|RL|LR)\b/;
const KEYWORD_PATTERN =
/^(subgraph|end|participant|actor|loop|alt|else|opt|par|critical|break|rect|note|over|activate|deactivate|title|section|class|style|linkStyle|classDef|click)\b/i;
const MERMAID_TOKEN_RULES: ReadonlyArray<{
pattern: RegExp;
type: MermaidHighlightTokenType | null;
}> = [
{ pattern: /^%%[^\n]*/, type: "comment" },
{ pattern: /^"(?:[^"\\]|\\.)*"/, type: "string" },
{ pattern: DIAGRAM_TYPE_PATTERN, type: "keyword" },
{ pattern: DIRECTION_PATTERN, type: "keyword" },
{ pattern: KEYWORD_PATTERN, type: "keyword" },
{ pattern: /^[-.=<>|ox]+>/, type: "operator" },
{ pattern: /^<[-.=<>|ox]+/, type: "operator" },
{ pattern: /^(--+|\.\.+|==+)/, type: "operator" },
{ pattern: /^[[\](){}|<>]/, type: "bracket" },
{ pattern: /^[A-Za-z_][A-Za-z0-9_]*/, type: "variableName" },
{ pattern: /^\d+(\.\d+)?/, type: "number" },
{ pattern: /^[,:;]/, type: "punctuation" },
{ pattern: /^\s+/, type: null },
];
export const getMermaidHighlightToken = (
input: string,
): MermaidHighlightToken | null => {
if (!input) {
return null;
}
for (const rule of MERMAID_TOKEN_RULES) {
const match = input.match(rule.pattern);
if (match) {
return {
type: rule.type,
value: match[0],
};
}
}
return {
type: null,
value: input[0],
};
};
export const tokenizeMermaid = (input: string): MermaidHighlightToken[] => {
const tokens: MermaidHighlightToken[] = [];
let remaining = input;
while (remaining) {
const token = getMermaidHighlightToken(remaining);
if (!token) {
break;
}
tokens.push(token);
remaining = remaining.slice(token.value.length);
}
return tokens;
};
@@ -1,79 +1,17 @@
import { StreamLanguage } from "@codemirror/language";
import { getMermaidHighlightToken } from "./mermaid-highlighting";
const mermaidStreamParser = StreamLanguage.define({
token(stream) {
// Comments: %%...
if (stream.match(/^%%.*$/)) {
return "comment";
}
// Strings
if (stream.match(/^"(?:[^"\\]|\\.)*"/)) {
return "string";
}
// Diagram type keywords (at start of line or after whitespace)
if (
stream.match(
/^(flowchart|graph|sequenceDiagram|classDiagram|stateDiagram|erDiagram|gantt|pie|mindmap|journey|gitGraph|timeline|quadrantChart|sankey|xychart)\b/i,
)
) {
return "keyword";
}
// Direction keywords
if (stream.match(/^(TB|TD|BT|RL|LR)\b/)) {
return "keyword";
}
// Keywords
if (
stream.match(
/^(subgraph|end|participant|actor|loop|alt|else|opt|par|critical|break|rect|note|over|activate|deactivate|title|section|class|style|linkStyle|classDef|click)\b/i,
)
) {
return "keyword";
}
// Arrows: -->, ---, -.->, ===>, etc.
if (stream.match(/^[-.=<>|ox]+>/)) {
return "operator";
}
if (stream.match(/^<[-.=<>|ox]+/)) {
return "operator";
}
if (stream.match(/^--+|\.\.+|==+/)) {
return "operator";
}
// Labels in brackets/parens: [text], (text), {text}, ((text)), etc.
if (stream.match(/^[[\](){}|<>]/)) {
return "bracket";
}
// Node IDs (alphanumeric)
if (stream.match(/^[A-Za-z_][A-Za-z0-9_]*/)) {
return "variableName";
}
// Numbers
if (stream.match(/^\d+(\.\d+)?/)) {
return "number";
}
// Punctuation
if (stream.match(/^[,:;]/)) {
return "punctuation";
}
// Skip whitespace
if (stream.eatSpace()) {
const token = getMermaidHighlightToken(stream.string.slice(stream.pos));
if (!token) {
stream.skipToEnd();
return null;
}
// Skip any other character
stream.next();
return null;
stream.pos += token.value.length;
return token.type;
},
});
@@ -18,6 +18,8 @@ export type MermaidData = {
files: BinaryFiles | null;
};
export type ChatMessageContentFormat = "text" | "mermaid";
export interface RateLimits {
rateLimit: number;
rateLimitRemaining: number;
@@ -33,6 +35,7 @@ export namespace TChat {
errorType?: "parse" | "network" | "other";
lastAttemptAt?: number;
type: "user" | "assistant" | "warning";
contentFormat?: ChatMessageContentFormat;
warningType?: /* daily rate limit */
"messageLimitExceeded" | /* general 429 */ "rateLimitExceeded";
content?: string;
@@ -7,6 +7,17 @@ import { chatHistoryAtom } from "./TTDContext";
import type { SavedChat, SavedChats, TTDPersistenceAdapter } from "./types";
const normalizePersistedChat = (chat: SavedChat): SavedChat => ({
...chat,
messages: chat.messages.map((message) => ({
...message,
// Legacy TTD chats predate explicit content format metadata.
contentFormat:
message.contentFormat ??
(message.type === "assistant" ? "mermaid" : undefined),
})),
});
interface UseTTDChatStorageProps {
persistenceAdapter: TTDPersistenceAdapter;
}
@@ -56,7 +67,7 @@ export const useTTDChatStorage = ({
setIsLoading(true);
try {
const chats = await persistenceAdapter.loadChats();
setSavedChats(chats);
setSavedChats(chats.map(normalizePersistedChat));
setChatsLoaded(true);
} catch (error) {
console.warn("Failed to load chats:", error);
@@ -117,6 +117,28 @@ describe("chat utils", () => {
expect(result.messages[0].errorType).toBe("network");
});
it("should update content format when provided", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "assistant",
content: "graph TD",
timestamp: new Date("2024-01-01"),
contentFormat: "text",
},
],
};
const result = updateAssistantContent(chatHistory, {
contentFormat: "mermaid",
});
expect(result.messages[0].contentFormat).toBe("mermaid");
});
it("should return unchanged chatHistory if no assistant message exists", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
@@ -357,6 +379,7 @@ describe("chat utils", () => {
{
type: "assistant",
content: "Message",
contentFormat: "mermaid",
isGenerating: true,
error: "Error text",
errorType: "parse",
@@ -364,6 +387,7 @@ describe("chat utils", () => {
]);
expect(result.messages[0].isGenerating).toBe(true);
expect(result.messages[0].contentFormat).toBe("mermaid");
expect(result.messages[0].error).toBe("Error text");
expect(result.messages[0].errorType).toBe("parse");
});