fix(messages): normalize escaped markdown newlines
This commit is contained in:
1 parent
6ce7e63c71
commit
31aac7c0de
9 files changed
+78
-7
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import type { DatabaseSync } from "node:sqlite";
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
import type { CommentRecord } from "@shared/types";
|
import type { CommentRecord } from "@shared/types";
|
||||||
|
import { normalizeMarkdownText } from "@shared/markdown-text";
|
||||||
import type { ReadAll } from "./helpers";
|
import type { ReadAll } from "./helpers";
|
||||||
import { nowIso } from "./helpers";
|
import { nowIso } from "./helpers";
|
||||||
|
|
||||||
@@ -19,12 +20,13 @@ export function buildActivityMethods(
|
|||||||
const taskRow = getDb().prepare("select company_id from tasks where id = ?").get(input.taskId) as { company_id: string } | undefined;
|
const taskRow = getDb().prepare("select company_id from tasks where id = ?").get(input.taskId) as { company_id: string } | undefined;
|
||||||
if (!taskRow) throw new Error("Task not found.");
|
if (!taskRow) throw new Error("Task not found.");
|
||||||
if (taskRow.company_id !== input.companyId) throw new Error("Task does not belong to this company.");
|
if (taskRow.company_id !== input.companyId) throw new Error("Task does not belong to this company.");
|
||||||
|
const body = normalizeMarkdownText(input.body);
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const ts = nowIso();
|
const ts = nowIso();
|
||||||
getDb().prepare(
|
getDb().prepare(
|
||||||
"insert into task_comments (id, company_id, task_id, author_agent_id, author_name, body, created_at) values (?, ?, ?, ?, ?, ?, ?)",
|
"insert into task_comments (id, company_id, task_id, author_agent_id, author_name, body, created_at) values (?, ?, ?, ?, ?, ?, ?)",
|
||||||
).run(id, input.companyId, input.taskId, input.authorAgentId, input.authorName, input.body, ts);
|
).run(id, input.companyId, input.taskId, input.authorAgentId, input.authorName, body, ts);
|
||||||
return { id, companyId: input.companyId, taskId: input.taskId, authorAgentId: input.authorAgentId, authorName: input.authorName, body: input.body, createdAt: ts };
|
return { id, companyId: input.companyId, taskId: input.taskId, authorAgentId: input.authorAgentId, authorName: input.authorName, body, createdAt: ts };
|
||||||
},
|
},
|
||||||
|
|
||||||
listComments(taskId: string): CommentRecord[] {
|
listComments(taskId: string): CommentRecord[] {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||||
import log from "electron-log/main.js";
|
import log from "electron-log/main.js";
|
||||||
import type { AgentMessageRecord, AgentRecord } from "@shared/types";
|
import type { AgentMessageRecord, AgentRecord } from "@shared/types";
|
||||||
|
import { normalizeMarkdownText } from "@shared/markdown-text";
|
||||||
import {
|
import {
|
||||||
BOARD_READER_ID,
|
BOARD_READER_ID,
|
||||||
type ReadAll,
|
type ReadAll,
|
||||||
@@ -66,6 +67,7 @@ export function buildMessageMethods(
|
|||||||
}
|
}
|
||||||
const senderKind = input.senderKind
|
const senderKind = input.senderKind
|
||||||
?? (input.fromAgentId === BOARD_READER_ID ? "human" : "agent");
|
?? (input.fromAgentId === BOARD_READER_ID ? "human" : "agent");
|
||||||
|
const body = normalizeMarkdownText(input.body);
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const createdAt = nowIso();
|
const createdAt = nowIso();
|
||||||
// Single transaction: the row and its mention fan-out must land
|
// Single transaction: the row and its mention fan-out must land
|
||||||
@@ -86,7 +88,7 @@ export function buildMessageMethods(
|
|||||||
channel,
|
channel,
|
||||||
input.channelTargetId ?? null,
|
input.channelTargetId ?? null,
|
||||||
input.subject,
|
input.subject,
|
||||||
input.body,
|
body,
|
||||||
input.priority ?? "normal",
|
input.priority ?? "normal",
|
||||||
input.parentMessageId ?? null,
|
input.parentMessageId ?? null,
|
||||||
input.attachmentsJson ?? "[]",
|
input.attachmentsJson ?? "[]",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { AppDatabase } from "../database";
|
import type { AppDatabase } from "../database";
|
||||||
import type { AgentMessageRecord, DesktopEvent, MessageChannel, MentionResult, SectionId } from "@shared/types";
|
import type { AgentMessageRecord, DesktopEvent, MessageChannel, MentionResult, SectionId } from "@shared/types";
|
||||||
|
import { normalizeMarkdownText } from "@shared/markdown-text";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The board reader / human participant ID.
|
* The board reader / human participant ID.
|
||||||
@@ -120,7 +121,7 @@ export function normalizeAgentMessageInput(input: AgentMessageDispatchInput): No
|
|||||||
channel,
|
channel,
|
||||||
channelTargetId,
|
channelTargetId,
|
||||||
subject,
|
subject,
|
||||||
body: input.body,
|
body: normalizeMarkdownText(input.body),
|
||||||
priority,
|
priority,
|
||||||
parentMessageId,
|
parentMessageId,
|
||||||
threadId,
|
threadId,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { formatMessageTime } from "../../lib/formatters"
|
|||||||
import { EmptyState, useReducedMotion } from "../ui"
|
import { EmptyState, useReducedMotion } from "../ui"
|
||||||
import { useRovingTabIndex } from "../../lib/keyboard"
|
import { useRovingTabIndex } from "../../lib/keyboard"
|
||||||
import { useT } from "../../i18n"
|
import { useT } from "../../i18n"
|
||||||
|
import { normalizeMarkdownText } from "@shared/markdown-text"
|
||||||
|
|
||||||
type ChannelConfig = { labelKey: string; icon: React.ElementType; color: string }
|
type ChannelConfig = { labelKey: string; icon: React.ElementType; color: string }
|
||||||
|
|
||||||
@@ -121,6 +122,7 @@ export function SearchResults({ results, query, loading, agents, onNavigateToRes
|
|||||||
const message = results[virtualRow.index]
|
const message = results[virtualRow.index]
|
||||||
const agent = agentMap.get(message.fromAgentId)
|
const agent = agentMap.get(message.fromAgentId)
|
||||||
const config = channelConfig[message.channel]
|
const config = channelConfig[message.channel]
|
||||||
|
const normalizedBody = normalizeMarkdownText(message.body)
|
||||||
const ChannelIcon = config.icon
|
const ChannelIcon = config.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -175,7 +177,7 @@ export function SearchResults({ results, query, loading, agents, onNavigateToRes
|
|||||||
|
|
||||||
{/* Row 3: Body snippet */}
|
{/* Row 3: Body snippet */}
|
||||||
<div className="mt-0.5 line-clamp-2 break-words text-[13px] leading-relaxed text-muted-foreground">
|
<div className="mt-0.5 line-clamp-2 break-words text-[13px] leading-relaxed text-muted-foreground">
|
||||||
{highlightMatch(message.body, query)}
|
{highlightMatch(normalizedBody, query)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import WaitStateIndicator, { type WaitStateInfo } from "./WaitStateIndicator"
|
|||||||
import { MessageComposer } from "./MessageComposer"
|
import { MessageComposer } from "./MessageComposer"
|
||||||
import { formatMessageTime } from "../../lib/formatters"
|
import { formatMessageTime } from "../../lib/formatters"
|
||||||
import { useT, type TranslationFn } from "../../i18n"
|
import { useT, type TranslationFn } from "../../i18n"
|
||||||
|
import { MarkdownBody } from "../ui/MarkdownBody"
|
||||||
|
|
||||||
interface ThreadPanelProps {
|
interface ThreadPanelProps {
|
||||||
threadId: string
|
threadId: string
|
||||||
@@ -51,7 +52,9 @@ function EventItem({ event, agents, t }: { event: ConversationEventRecord; agent
|
|||||||
<span className="text-xs font-medium text-foreground">{actorName}</span>
|
<span className="text-xs font-medium text-foreground">{actorName}</span>
|
||||||
<span className="text-xs text-muted-foreground">{time}</span>
|
<span className="text-xs text-muted-foreground">{time}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-foreground/80">{event.body}</p>
|
<div className="text-sm text-foreground/80">
|
||||||
|
<MarkdownBody body={event.body} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
|
import { normalizeMarkdownText } from "@shared/markdown-text";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-only markdown renderer used by message bubbles, comment bodies, and
|
* Read-only markdown renderer used by message bubbles, comment bodies, and
|
||||||
@@ -21,6 +22,8 @@ import remarkGfm from "remark-gfm";
|
|||||||
* wrapping container.
|
* wrapping container.
|
||||||
*/
|
*/
|
||||||
export const MarkdownBody = memo(function MarkdownBody({ body }: { body: string }) {
|
export const MarkdownBody = memo(function MarkdownBody({ body }: { body: string }) {
|
||||||
|
const normalizedBody = normalizeMarkdownText(body);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
@@ -76,7 +79,7 @@ export const MarkdownBody = memo(function MarkdownBody({ body }: { body: string
|
|||||||
hr: () => <hr className="my-2 border-border" />,
|
hr: () => <hr className="my-2 border-border" />,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{body}
|
{normalizedBody}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
const ACTUAL_LINE_BREAKS = /\r\n?/g;
|
||||||
|
const ESCAPED_CRLF = /\\r\\n/g;
|
||||||
|
const ESCAPED_LF = /\\n/g;
|
||||||
|
const ESCAPED_CR = /\\r/g;
|
||||||
|
|
||||||
|
export function normalizeMarkdownText(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(ACTUAL_LINE_BREAKS, "\n")
|
||||||
|
.replace(ESCAPED_CRLF, "\n")
|
||||||
|
.replace(ESCAPED_LF, "\n")
|
||||||
|
.replace(ESCAPED_CR, "\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { normalizeMarkdownText } from "../../src/shared/markdown-text";
|
||||||
|
|
||||||
|
describe("normalizeMarkdownText", () => {
|
||||||
|
it("converts escaped markdown line breaks into renderable markdown", () => {
|
||||||
|
expect(normalizeMarkdownText("Intro\\n\\n## Plan\\n- First\\n- Second")).toBe("Intro\n\n## Plan\n- First\n- Second");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes actual CRLF input to LF", () => {
|
||||||
|
expect(normalizeMarkdownText("One\r\nTwo\rThree")).toBe("One\nTwo\nThree");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -200,6 +200,40 @@ describe("message dispatch", () => {
|
|||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes escaped markdown line breaks before persisting messages and comments", async () => {
|
||||||
|
const profileDir = await createTempDir("agentcompany-message-markdown-");
|
||||||
|
const db = new AppDatabase(profileDir);
|
||||||
|
db.init();
|
||||||
|
|
||||||
|
const { companyId, ceoId, ctoId, taskId } = createScaffold(db);
|
||||||
|
|
||||||
|
const result = dispatchAgentMessage({
|
||||||
|
db,
|
||||||
|
}, {
|
||||||
|
companyId,
|
||||||
|
fromAgentId: ctoId,
|
||||||
|
channel: "direct",
|
||||||
|
toAgentId: ceoId,
|
||||||
|
subject: "Markdown update",
|
||||||
|
body: "CEO,\\n\\n## Plan\\n- Confirm scope\\n- Ship cleanly",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) throw new Error("Expected dispatch to succeed.");
|
||||||
|
expect(result.data.message?.body).toBe("CEO,\n\n## Plan\n- Confirm scope\n- Ship cleanly");
|
||||||
|
|
||||||
|
const comment = db.core.activity.addComment({
|
||||||
|
companyId,
|
||||||
|
taskId,
|
||||||
|
authorAgentId: ctoId,
|
||||||
|
authorName: "CTO",
|
||||||
|
body: "Progress:\\n- Scope confirmed\\n- Review queued",
|
||||||
|
});
|
||||||
|
expect(comment.body).toBe("Progress:\n- Scope confirmed\n- Review queued");
|
||||||
|
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("redirects a DM's wake to the @mentioned agent instead of the DM recipient (chatroom semantics)", async () => {
|
it("redirects a DM's wake to the @mentioned agent instead of the DM recipient (chatroom semantics)", async () => {
|
||||||
const profileDir = await createTempDir("agentcompany-message-redirect-");
|
const profileDir = await createTempDir("agentcompany-message-redirect-");
|
||||||
const db = new AppDatabase(profileDir);
|
const db = new AppDatabase(profileDir);
|
||||||
|
|||||||
Reference in new issue
Block a user