diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 3907ef764c5a5..19cb0baad0650 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -828,6 +828,25 @@ describe("MessagesTimeline messages", () => { expect((globalThis as { __t3Xss?: number }).__t3Xss).toBeUndefined(); }); + it("keeps single tildes in user paths literal without changing double tildes or assistant text", async () => { + const source = + "Install the skill as ~/folder-a/skills/x, then follow the guide (~/folder-a/guide.md)."; + await using screen = await renderTimeline([ + message(source), + message("Drop ~~the old plan~~", {}, "message-2"), + message(source, { role: "assistant" }, "assistant-1"), + ]); + const [user, doubleTilde, assistant] = screen.container.querySelectorAll( + "[data-timeline-row-kind=message]", + ); + expect(user?.textContent).toContain(source); + expect(user?.querySelector("del")).toBeNull(); + expect(doubleTilde?.querySelector("del")?.textContent).toBe("the old plan"); + expect(assistant?.querySelector("del")?.textContent).toBe( + "/folder-a/skills/x, then follow the guide (", + ); + }); + it("renders sanitized raw HTML in assistant messages", async () => { await using screen = await renderTimeline([ message( diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index ae55a3214da9e..c4593d2f6c5e1 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -1,6 +1,9 @@ import { InlineAudioPreview } from "../files/InlineAudioPreview"; import { filePreviewKind } from "@t3tools/shared/filePreview"; -import { remarkEnableOrderedListNumbers } from "@t3tools/shared/markdownPipeline"; +import { + CHAT_USER_MARKDOWN_REMARK_PLUGINS, + remarkEnableOrderedListNumbers, +} from "@t3tools/shared/markdownPipeline"; import { findRevealRef } from "./markdownFindContext"; import { type EnvironmentId, @@ -1200,7 +1203,10 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop ); }); -const USER_MESSAGE_REMARK_PLUGINS = [remarkEnableOrderedListNumbers]; +const USER_MESSAGE_REMARK_PLUGINS = [ + ...CHAT_USER_MARKDOWN_REMARK_PLUGINS, + remarkEnableOrderedListNumbers, +]; const UserMessageBody = memo(function UserMessageBody(props: { text: string; diff --git a/packages/shared/src/markdownPipeline.ts b/packages/shared/src/markdownPipeline.ts index e8042ee6d4696..a549045259e49 100644 --- a/packages/shared/src/markdownPipeline.ts +++ b/packages/shared/src/markdownPipeline.ts @@ -274,6 +274,12 @@ export const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS: PluggableList = [ remarkNormalizeLinksAndTagInlineCode, ]; +// Users type paths like ~/a and (~/b, which GFM's single-tilde strikethrough hides as . +// Appended after the base list, this reconfigures its remark-gfm entry; ~~double~~ still strikes. +export const CHAT_USER_MARKDOWN_REMARK_PLUGINS: PluggableList = [ + [remarkGfm, { singleTilde: false }], +]; + // Start tags an HTML parser closes an open `

` for. const CLOSES_PARAGRAPH = new Set( "address article aside blockquote center details dialog dir div dl fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6 header hgroup hr li listing main menu nav ol p plaintext pre search section summary table ul xmp dd dt".split( diff --git a/packages/shared/src/threadFindText.test.ts b/packages/shared/src/threadFindText.test.ts index d1929afd1ea1d..3dabff0d8b4d0 100644 --- a/packages/shared/src/threadFindText.test.ts +++ b/packages/shared/src/threadFindText.test.ts @@ -21,6 +21,14 @@ it("excludes review attachments rendered as context chips", () => { ]); }); +it("indexes single tildes in user paths as typed", () => { + const text = "Install as ~/a/x, then read (~/a/guide.md)."; + expect(searchableMessageSegments({ role: "user", text, streaming: false })?.join("")).toBe(text); + expect( + searchableMessageSegments({ role: "user", text: "Drop ~~old~~", streaming: false })?.join(""), + ).toBe("Drop old"); +}); + it("excludes structured context chips without joining text across them", () => { expect( searchableMessageSegments({ diff --git a/packages/shared/src/threadFindText.ts b/packages/shared/src/threadFindText.ts index 871f14562f9e5..7c2b9006a16d8 100644 --- a/packages/shared/src/threadFindText.ts +++ b/packages/shared/src/threadFindText.ts @@ -37,6 +37,7 @@ import { CHAT_MARKDOWN_REHYPE_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, + CHAT_USER_MARKDOWN_REMARK_PLUGINS, } from "./markdownPipeline.ts"; // Inline wrappers (including Shiki token spans) must not split a search phrase. @@ -94,6 +95,7 @@ const insightBreaksProcessor = assistantTextProcessor(true, true); const userProcessor = unified() .use(remarkParse) .use(CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS) + .use(CHAT_USER_MARKDOWN_REMARK_PLUGINS) .use(remarkRehype, { allowDangerousHtml: true }); interface TextTree {