Skip to content

Commit a652fd5

Browse files
committed
Align thread search with rendered markdown text
1 parent 50affe9 commit a652fd5

File tree

4 files changed

+207
-22
lines changed

4 files changed

+207
-22
lines changed

apps/web/src/components/chat/threadSearch.test.ts

Lines changed: 128 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,27 @@ const rows: TimelineRow[] = [
3333
],
3434
},
3535
},
36+
{
37+
kind: "message",
38+
id: "assistant-markdown-row",
39+
createdAt: "2026-03-28T12:00:02.000Z",
40+
durationStart: "2026-03-28T12:00:02.000Z",
41+
showCompletionDivider: false,
42+
message: {
43+
id: MessageId.makeUnsafe("message-1a"),
44+
role: "assistant",
45+
text: [
46+
"See [thread search docs](https://example.com/thread-search) for **alpha marker** details.",
47+
"",
48+
"```ts",
49+
"const planSeed = 'seeded';",
50+
"```",
51+
].join("\n"),
52+
createdAt: "2026-03-28T12:00:02.000Z",
53+
streaming: false,
54+
attachments: [],
55+
},
56+
},
3657
{
3758
kind: "message",
3859
id: "user-message-row",
@@ -110,13 +131,36 @@ const rows: TimelineRow[] = [
110131
proposedPlan: {
111132
id: "plan-1" as never,
112133
turnId: null,
113-
planMarkdown: "1. Add thread search\n2. Jump to the matching row",
134+
planMarkdown: [
135+
"# Seeded Thread Search Plan",
136+
"",
137+
"## Summary",
138+
"",
139+
"1. Add **thread search**",
140+
"2. Jump to the matching row",
141+
"3. Review [plan docs](https://example.com/plan-docs)",
142+
].join("\n"),
114143
implementedAt: null,
115144
implementationThreadId: null,
116145
createdAt: "2026-03-28T12:00:20.000Z",
117146
updatedAt: "2026-03-28T12:00:20.000Z",
118147
},
119148
},
149+
{
150+
kind: "message",
151+
id: "assistant-empty-row",
152+
createdAt: "2026-03-28T12:00:25.000Z",
153+
durationStart: "2026-03-28T12:00:25.000Z",
154+
showCompletionDivider: false,
155+
message: {
156+
id: MessageId.makeUnsafe("message-empty"),
157+
role: "assistant",
158+
text: "",
159+
createdAt: "2026-03-28T12:00:25.000Z",
160+
streaming: false,
161+
attachments: [],
162+
},
163+
},
120164
{
121165
kind: "working",
122166
id: "working-row",
@@ -133,8 +177,15 @@ describe("findThreadSearchResults", () => {
133177
normalizedTexts: ["needle in the response. another needle is here."],
134178
},
135179
{
136-
rowId: "user-message-row",
180+
rowId: "assistant-markdown-row",
137181
rowIndex: 1,
182+
normalizedTexts: [
183+
"see thread search docs for alpha marker details.\n\nconst planseed = 'seeded';",
184+
],
185+
},
186+
{
187+
rowId: "user-message-row",
188+
rowIndex: 2,
138189
normalizedTexts: [
139190
"visible composer text @terminal-1:1-5",
140191
"terminal 1 lines 1-5",
@@ -143,12 +194,12 @@ describe("findThreadSearchResults", () => {
143194
},
144195
{
145196
rowId: "work-row",
146-
rowIndex: 2,
197+
rowIndex: 3,
147198
normalizedTexts: ["edit readme", "bun run lint", "readme.md"],
148199
},
149200
{
150201
rowId: "work-row-visible-files",
151-
rowIndex: 3,
202+
rowIndex: 4,
152203
normalizedTexts: [
153204
"apply patch",
154205
"git status",
@@ -160,12 +211,20 @@ describe("findThreadSearchResults", () => {
160211
},
161212
{
162213
rowId: "plan-row",
163-
rowIndex: 4,
164-
normalizedTexts: ["1. add thread search\n2. jump to the matching row"],
214+
rowIndex: 5,
215+
normalizedTexts: [
216+
"seeded thread search plan",
217+
"add thread search\njump to the matching row\nreview plan docs",
218+
],
219+
},
220+
{
221+
rowId: "assistant-empty-row",
222+
rowIndex: 6,
223+
normalizedTexts: ["(empty response)"],
165224
},
166225
{
167226
rowId: "working-row",
168-
rowIndex: 5,
227+
rowIndex: 7,
169228
normalizedTexts: [],
170229
},
171230
]);
@@ -185,7 +244,7 @@ describe("findThreadSearchResults", () => {
185244
expect(findThreadSearchResults(rows, "readme")).toEqual([
186245
{
187246
rowId: "work-row",
188-
rowIndex: 2,
247+
rowIndex: 3,
189248
matchCount: 2,
190249
},
191250
]);
@@ -195,17 +254,17 @@ describe("findThreadSearchResults", () => {
195254
expect(findThreadSearchResults(rows, "edit readme")).toEqual([
196255
{
197256
rowId: "work-row",
198-
rowIndex: 2,
257+
rowIndex: 3,
199258
matchCount: 1,
200259
},
201260
]);
202261
});
203262

204-
it("matches proposed plans and ignores the working indicator", () => {
205-
expect(findThreadSearchResults(rows, "thread search")).toEqual([
263+
it("matches displayed proposed-plan body content and ignores the working indicator", () => {
264+
expect(findThreadSearchResults(rows, "jump to the matching row")).toEqual([
206265
{
207266
rowId: "plan-row",
208-
rowIndex: 4,
267+
rowIndex: 5,
209268
matchCount: 1,
210269
},
211270
]);
@@ -218,21 +277,21 @@ describe("findThreadSearchResults", () => {
218277
expect(findThreadSearchResults(rows, "visible composer text")).toEqual([
219278
{
220279
rowId: "user-message-row",
221-
rowIndex: 1,
280+
rowIndex: 2,
222281
matchCount: 1,
223282
},
224283
]);
225284
expect(findThreadSearchResults(rows, "terminal 1 lines 1-5")).toEqual([
226285
{
227286
rowId: "user-message-row",
228-
rowIndex: 1,
287+
rowIndex: 2,
229288
matchCount: 1,
230289
},
231290
]);
232291
expect(findThreadSearchResults(rows, "visible-upload-name")).toEqual([
233292
{
234293
rowId: "user-message-row",
235-
rowIndex: 1,
294+
rowIndex: 2,
236295
matchCount: 1,
237296
},
238297
]);
@@ -242,21 +301,68 @@ describe("findThreadSearchResults", () => {
242301
expect(findThreadSearchResults(rows, "apply patch")).toEqual([
243302
{
244303
rowId: "work-row-visible-files",
245-
rowIndex: 3,
304+
rowIndex: 4,
246305
matchCount: 1,
247306
},
248307
]);
249308
expect(findThreadSearchResults(rows, "completed")).toEqual([]);
250309
expect(findThreadSearchResults(rows, "src/d.ts")).toEqual([
251310
{
252311
rowId: "work-row-visible-files",
253-
rowIndex: 3,
312+
rowIndex: 4,
254313
matchCount: 1,
255314
},
256315
]);
257316
expect(findThreadSearchResults(rows, "src/e.ts")).toEqual([]);
258317
});
259318

319+
it("indexes assistant markdown by rendered text instead of raw markdown syntax", () => {
320+
expect(findThreadSearchResults(rows, "thread search docs")).toEqual([
321+
{
322+
rowId: "assistant-markdown-row",
323+
rowIndex: 1,
324+
matchCount: 1,
325+
},
326+
]);
327+
expect(findThreadSearchResults(rows, "alpha marker")).toEqual([
328+
{
329+
rowId: "assistant-markdown-row",
330+
rowIndex: 1,
331+
matchCount: 1,
332+
},
333+
]);
334+
expect(findThreadSearchResults(rows, "https://example.com/thread-search")).toEqual([]);
335+
});
336+
337+
it("indexes proposed plans by displayed title and body instead of raw markdown", () => {
338+
expect(findThreadSearchResults(rows, "seeded thread search plan")).toEqual([
339+
{
340+
rowId: "plan-row",
341+
rowIndex: 5,
342+
matchCount: 1,
343+
},
344+
]);
345+
expect(findThreadSearchResults(rows, "plan docs")).toEqual([
346+
{
347+
rowId: "plan-row",
348+
rowIndex: 5,
349+
matchCount: 1,
350+
},
351+
]);
352+
expect(findThreadSearchResults(rows, "summary")).toEqual([]);
353+
expect(findThreadSearchResults(rows, "https://example.com/plan-docs")).toEqual([]);
354+
});
355+
356+
it("indexes the rendered empty assistant placeholder text", () => {
357+
expect(findThreadSearchResults(rows, "(empty response)")).toEqual([
358+
{
359+
rowId: "assistant-empty-row",
360+
rowIndex: 6,
361+
matchCount: 1,
362+
},
363+
]);
364+
});
365+
260366
it("returns no results for empty queries", () => {
261367
expect(findThreadSearchResults(rows, " ")).toEqual([]);
262368
});
@@ -265,7 +371,7 @@ describe("findThreadSearchResults", () => {
265371
expect(findThreadSearchResults(rows, "row")).toEqual([
266372
{
267373
rowId: "plan-row",
268-
rowIndex: 4,
374+
rowIndex: 5,
269375
matchCount: 1,
270376
},
271377
]);
@@ -299,7 +405,10 @@ describe("findThreadSearchResults", () => {
299405
const previousState = findThreadSearchLookupState(index, "thread search");
300406
const nextState = findThreadSearchLookupState(index, "e", previousState);
301407

302-
expect(previousState.matchingEntries.map((entry) => entry.rowId)).toEqual(["plan-row"]);
408+
expect(previousState.matchingEntries.map((entry) => entry.rowId)).toEqual([
409+
"assistant-markdown-row",
410+
"plan-row",
411+
]);
303412
expect(nextState.results).toEqual(findThreadSearchResultsFromIndex(index, "e"));
304413
});
305414
});

apps/web/src/components/chat/threadSearch.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
renderableWorkEntryPreview,
55
type TimelineRow,
66
} from "./MessagesTimeline.logic";
7+
import { stripDisplayedPlanMarkdown, proposedPlanTitle } from "~/proposedPlan";
8+
import { markdownToPlainText } from "~/lib/markdownPlainText";
79
import { deriveDisplayedUserMessageState } from "~/lib/terminalContext";
810

911
export interface ThreadSearchResult {
@@ -50,6 +52,10 @@ function countMatches(haystack: string, needle: string): number {
5052
function collectRowSearchText(row: TimelineRow): string[] {
5153
switch (row.kind) {
5254
case "message": {
55+
const visibleAssistantText =
56+
row.message.role === "assistant"
57+
? row.message.text || (row.message.streaming ? "" : "(empty response)")
58+
: "";
5359
const visibleMessageState =
5460
row.message.role === "user" ? deriveDisplayedUserMessageState(row.message.text) : null;
5561
const visibleAttachmentNames =
@@ -63,13 +69,18 @@ function collectRowSearchText(row: TimelineRow): string[] {
6369
? (visibleMessageState?.contexts.map((context) => context.header) ?? [])
6470
: [];
6571
return [
66-
row.message.role === "user" ? (visibleMessageState?.visibleText ?? "") : row.message.text,
72+
row.message.role === "user"
73+
? (visibleMessageState?.visibleText ?? "")
74+
: markdownToPlainText(visibleAssistantText),
6775
...visibleTerminalChipLabels,
6876
...visibleAttachmentNames,
6977
];
7078
}
71-
case "proposed-plan":
72-
return [row.proposedPlan.planMarkdown];
79+
case "proposed-plan": {
80+
const title = proposedPlanTitle(row.proposedPlan.planMarkdown) ?? "";
81+
const displayedBody = stripDisplayedPlanMarkdown(row.proposedPlan.planMarkdown);
82+
return [title, markdownToPlainText(displayedBody)];
83+
}
7384
case "work":
7485
return row.groupedEntries.flatMap((entry) => [
7586
renderableWorkEntryHeading(entry),
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { markdownToPlainText } from "./markdownPlainText";
4+
5+
describe("markdownToPlainText", () => {
6+
it("keeps rendered link text and removes raw markdown destinations", () => {
7+
expect(
8+
markdownToPlainText("See [thread search docs](https://example.com/thread-search) next."),
9+
).toBe("See thread search docs next.");
10+
});
11+
12+
it("keeps visible code text while removing markdown fence syntax", () => {
13+
expect(markdownToPlainText("```ts\nconst marker = 'alpha';\n```")).toBe(
14+
"const marker = 'alpha';",
15+
);
16+
});
17+
18+
it("removes the first-line markdown structure while preserving visible content", () => {
19+
expect(
20+
markdownToPlainText("# Heading\n\n## Summary\n\n- **alpha marker**\n- `thread search`"),
21+
).toBe("Heading\nSummary\nalpha marker\nthread search");
22+
});
23+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
const FENCED_CODE_BLOCK_REGEX = /(^|\n)(`{3,}|~{3,})[^\n]*\n([\s\S]*?)\n\2(?=\n|$)/g;
2+
const INLINE_CODE_REGEX = /`([^`\n]+)`/g;
3+
const IMAGE_LINK_REGEX = /!\[([^\]]*)\]\((?:\\.|[^)])*\)/g;
4+
const INLINE_LINK_REGEX = /\[([^\]]+)\]\((?:\\.|[^)])*\)/g;
5+
const REFERENCE_LINK_REGEX = /\[([^\]]+)\]\[[^\]]*]/g;
6+
const REFERENCE_DEFINITION_REGEX = /^\s{0,3}\[[^\]]+]:\s+\S+(?:\s+.+)?$/gm;
7+
const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g;
8+
const AUTOLINK_REGEX = /<((?:https?|mailto):[^>\s]+)>/g;
9+
const HTML_TAG_REGEX = /<\/?[^>\n]+>/g;
10+
const HEADING_PREFIX_REGEX = /^\s{0,3}#{1,6}\s+/gm;
11+
const BLOCKQUOTE_PREFIX_REGEX = /^\s{0,3}>\s?/gm;
12+
const LIST_PREFIX_REGEX = /^\s{0,3}(?:[-+*]|\d+[.)])\s+/gm;
13+
const THEMATIC_BREAK_REGEX = /^\s{0,3}(?:[-*_]\s*){3,}$/gm;
14+
const EMPHASIS_REGEXES = [/(\*\*\*|___)(.*?)\1/gs, /(\*\*|__)(.*?)\1/gs, /(~~)(.*?)\1/gs] as const;
15+
16+
export function markdownToPlainText(markdown: string): string {
17+
let text = markdown.replace(/\r\n?/g, "\n");
18+
19+
text = text.replace(HTML_COMMENT_REGEX, " ");
20+
text = text.replace(
21+
FENCED_CODE_BLOCK_REGEX,
22+
(_match, prefix: string, _fence: string, code: string) => `${prefix}${code}\n`,
23+
);
24+
text = text.replace(IMAGE_LINK_REGEX, "$1");
25+
text = text.replace(INLINE_LINK_REGEX, "$1");
26+
text = text.replace(REFERENCE_LINK_REGEX, "$1");
27+
text = text.replace(REFERENCE_DEFINITION_REGEX, "");
28+
text = text.replace(AUTOLINK_REGEX, "$1");
29+
text = text.replace(INLINE_CODE_REGEX, "$1");
30+
31+
for (const regex of EMPHASIS_REGEXES) {
32+
text = text.replace(regex, "$2");
33+
}
34+
35+
text = text.replace(HEADING_PREFIX_REGEX, "");
36+
text = text.replace(BLOCKQUOTE_PREFIX_REGEX, "");
37+
text = text.replace(LIST_PREFIX_REGEX, "");
38+
text = text.replace(THEMATIC_BREAK_REGEX, "");
39+
text = text.replace(HTML_TAG_REGEX, " ");
40+
41+
return text.trim();
42+
}

0 commit comments

Comments
 (0)