Skip to content

Commit 2bf2f66

Browse files
committed
fix: handle Unicode smart quotes in title sanitization
normalizeQuotes() replaces curly/smart quotes (U+2018–U+201E) with ASCII equivalents before conversational-prefix regex matching, so titles like "I'm pulling the site copy..." are properly detected and cleaned in artifact cards, board tasks, and agent outputs.
1 parent 767b235 commit 2bf2f66

File tree

5 files changed

+99
-4
lines changed

5 files changed

+99
-4
lines changed

apps/desktop/src/features/board/lib/sanitize-task-title.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,42 @@ test("strips nested conversational prefix after 'OK,'", () => {
165165
"Put together the launch plan",
166166
);
167167
});
168+
169+
// ---------------------------------------------------------------------------
170+
// Smart quote (Unicode U+2019) support
171+
// ---------------------------------------------------------------------------
172+
173+
test("strips smart-quote I\u2019m prefix and capitalizes", () => {
174+
assert.equal(
175+
sanitizeTaskTitle("I\u2019m pulling the site copy/source so the recommendations are grounded"),
176+
"Pulling the site copy/source so the recommendations are grounded",
177+
);
178+
});
179+
180+
test("strips smart-quote I\u2019ll prefix and capitalizes", () => {
181+
assert.equal(
182+
sanitizeTaskTitle("I\u2019ll start by putting together a launch plan for Product Hunt"),
183+
"Start by putting together a launch plan for Product Hunt",
184+
);
185+
});
186+
187+
test("strips smart-quote I\u2019ve prefix", () => {
188+
assert.equal(
189+
sanitizeTaskTitle("I\u2019ve finished the competitive analysis"),
190+
"Finished the competitive analysis",
191+
);
192+
});
193+
194+
test("strips smart-quote don\u2019t prefix", () => {
195+
assert.equal(
196+
sanitizeTaskTitle("I don\u2019t see any major issues with the copy"),
197+
"See any major issues with the copy",
198+
);
199+
});
200+
201+
test("strips nested smart-quote prefix after 'OK,'", () => {
202+
assert.equal(
203+
sanitizeTaskTitle("OK, I\u2019ll put together the launch plan"),
204+
"Put together the launch plan",
205+
);
206+
});

apps/desktop/src/features/board/lib/sanitize-task-title.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { stripMarkdown } from "../../dashboard/lib/strip-markdown";
2+
import { normalizeQuotes } from "../../dashboard/lib/normalize-quotes";
23

34
/**
45
* Conversational prefixes that AI agents commonly start sentences with.
@@ -23,8 +24,8 @@ const NESTED_PREFIX =
2324
export function sanitizeTaskTitle(title: string): string {
2425
if (!title || !title.trim()) return "";
2526

26-
// Strip markdown first
27-
let clean = stripMarkdown(title);
27+
// Strip markdown first, then normalize smart quotes for regex matching
28+
let clean = normalizeQuotes(stripMarkdown(title));
2829

2930
// Strip outer conversational prefix
3031
clean = clean.replace(CONVERSATIONAL_PREFIX, "");
@@ -34,7 +35,7 @@ export function sanitizeTaskTitle(title: string): string {
3435

3536
// Remove leading articles after stripping ("a ", "the ", "an ")
3637
// Only after conversational prefix was stripped, to make titles more concise
37-
if (clean !== stripMarkdown(title)) {
38+
if (clean !== normalizeQuotes(stripMarkdown(title))) {
3839
clean = clean.replace(/^(a |an |the )/i, "");
3940
}
4041

apps/desktop/src/features/dashboard/lib/clean-artifact-title.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,48 @@ void test("cleanArtifactTitle: strips markdown from content heading", () => {
138138
"Bold Heading",
139139
);
140140
});
141+
142+
// ---------------------------------------------------------------------------
143+
// Smart quote (Unicode U+2019) support
144+
// ---------------------------------------------------------------------------
145+
146+
void test("isConversationalTitle: detects smart-quote I\u2019m prefix", () => {
147+
assert.ok(isConversationalTitle("I\u2019m pulling the site copy"));
148+
});
149+
150+
void test("isConversationalTitle: detects smart-quote I\u2019ll prefix", () => {
151+
assert.ok(isConversationalTitle("I\u2019ll start by reviewing the homepage"));
152+
});
153+
154+
void test("isConversationalTitle: detects smart-quote I\u2019ve prefix", () => {
155+
assert.ok(isConversationalTitle("I\u2019ve finished the audit"));
156+
});
157+
158+
void test("isConversationalTitle: detects smart-quote don\u2019t prefix", () => {
159+
assert.ok(isConversationalTitle("I don\u2019t see any issues"));
160+
});
161+
162+
void test("isConversationalTitle: detects smart-quote can\u2019t prefix", () => {
163+
assert.ok(isConversationalTitle("I can\u2019t find the source"));
164+
});
165+
166+
void test("cleanArtifactTitle: falls back to type label for smart-quote conversational title", () => {
167+
assert.equal(
168+
cleanArtifactTitle({
169+
title: "I\u2019m still only seeing the handoff sentence, not the actua...",
170+
type: "strategy_note",
171+
}),
172+
"Strategy Note",
173+
);
174+
});
175+
176+
void test("cleanArtifactTitle: extracts heading from content for smart-quote title", () => {
177+
assert.equal(
178+
cleanArtifactTitle({
179+
title: "I\u2019m still only seeing the handoff sentence",
180+
type: "strategy_note",
181+
content: "# Positioning Strategy\n\nDetails...",
182+
}),
183+
"Positioning Strategy",
184+
);
185+
});

apps/desktop/src/features/dashboard/lib/clean-artifact-title.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { stripMarkdown } from "./strip-markdown";
22
import { getArtifactTypeConfig } from "./artifact-type-config";
3+
import { normalizeQuotes } from "./normalize-quotes";
34

45
/**
56
* Regex matching first-person conversational preamble that should not
@@ -13,7 +14,7 @@ export const CONVERSATIONAL_PATTERN =
1314
* rather than a descriptive deliverable name.
1415
*/
1516
export function isConversationalTitle(title: string): boolean {
16-
return CONVERSATIONAL_PATTERN.test(title.trim());
17+
return CONVERSATIONAL_PATTERN.test(normalizeQuotes(title.trim()));
1718
}
1819

1920
/**
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* Replaces Unicode smart/curly quotes with ASCII equivalents
3+
* so regex patterns using ' and " match consistently.
4+
*/
5+
export function normalizeQuotes(text: string): string {
6+
return text
7+
.replace(/[\u2018\u2019\u201A]/g, "'") // ' ' ‚ → '
8+
.replace(/[\u201C\u201D\u201E]/g, '"'); // " " „ → "
9+
}

0 commit comments

Comments
 (0)