Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions apps/code/src/renderer/features/message-editor/utils/content.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { describe, expect, it } from "vitest";
import { contentToXml, type EditorContent, xmlToContent } from "./content";

describe("xmlToContent", () => {
it("parses a file tag into a file chip", () => {
const result = xmlToContent('<file path="src/foo/bar.ts" />');
expect(result).toEqual({
segments: [
{
type: "chip",
chip: { type: "file", id: "src/foo/bar.ts", label: "foo/bar.ts" },
},
],
});
});

it("derives file label from the final path segment when no parent", () => {
const result = xmlToContent('<file path="README.md" />');
expect(result.segments).toEqual([
{
type: "chip",
chip: { type: "file", id: "README.md", label: "README.md" },
},
]);
});

it("unescapes XML attributes", () => {
const result = xmlToContent('<file path="a/&quot;weird&quot;.ts" />');
const segment = result.segments[0];
expect(segment.type).toBe("chip");
if (segment.type === "chip") {
expect(segment.chip.id).toBe('a/"weird".ts');
}
});

it("parses github_issue tags with title", () => {
const xml =
'<github_issue number="42" title="Fix bug" url="https://github.com/org/repo/issues/42" />';
expect(xmlToContent(xml).segments).toEqual([
{
type: "chip",
chip: {
type: "github_issue",
id: "https://github.com/org/repo/issues/42",
label: "#42 - Fix bug",
},
},
]);
});

it("parses github_issue tags without title", () => {
const xml =
'<github_issue number="7" url="https://github.com/org/repo/issues/7" />';
const segment = xmlToContent(xml).segments[0];
expect(segment.type).toBe("chip");
if (segment.type === "chip") {
expect(segment.chip.label).toBe("#7");
}
});

it.each([
["error", "err-1"],
["experiment", "exp-1"],
["insight", "ins-1"],
["feature_flag", "flag-1"],
])("parses %s tag into a chip with id as label", (type, id) => {
const xml = `<${type} id="${id}" />`;
expect(xmlToContent(xml).segments).toEqual([
{ type: "chip", chip: { type, id, label: id } },
]);
});

it("preserves surrounding text around chips", () => {
const result = xmlToContent(
'please review <file path="src/a.ts" /> and <file path="src/b.ts" />',
);
expect(result.segments).toEqual([
{ type: "text", text: "please review " },
{
type: "chip",
chip: { type: "file", id: "src/a.ts", label: "src/a.ts" },
},
{ type: "text", text: " and " },
{
type: "chip",
chip: { type: "file", id: "src/b.ts", label: "src/b.ts" },
},
]);
});

it("returns a single text segment when no tags are present", () => {
expect(xmlToContent("just plain text").segments).toEqual([
{ type: "text", text: "just plain text" },
]);
});

it("returns a single text segment for empty input", () => {
expect(xmlToContent("").segments).toEqual([{ type: "text", text: "" }]);
});

it("round-trips contentToXml for a mix of text and chips", () => {
const content: EditorContent = {
segments: [
{ type: "text", text: "look at " },
{
type: "chip",
chip: { type: "file", id: "apps/code/src/a.ts", label: "src/a.ts" },
},
{ type: "text", text: " and " },
{
type: "chip",
chip: {
type: "github_issue",
id: "https://github.com/org/repo/issues/9",
label: "#9 - Thing",
},
},
],
};

const xml = contentToXml(content);
const parsed = xmlToContent(xml);
expect(parsed.segments).toEqual([
{ type: "text", text: "look at " },
{
type: "chip",
chip: { type: "file", id: "apps/code/src/a.ts", label: "src/a.ts" },
},
{ type: "text", text: " and " },
{
type: "chip",
chip: {
type: "github_issue",
id: "https://github.com/org/repo/issues/9",
label: "#9 - Thing",
},
},
]);
});
});
77 changes: 76 additions & 1 deletion apps/code/src/renderer/features/message-editor/utils/content.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { escapeXmlAttr } from "@utils/xml";
import { escapeXmlAttr, unescapeXmlAttr } from "@utils/xml";

export interface MentionChip {
type:
Expand Down Expand Up @@ -80,6 +80,81 @@ export function contentToXml(content: EditorContent): string {
return parts.join("");
}

const CHIP_TAG_REGEX =
/<(file|error|experiment|insight|feature_flag|github_issue)\b([^>]*?)\s*\/>/g;
const ATTR_REGEX = /(\w+)="([^"]*)"/g;

function deriveFileLabel(filePath: string): string {
const segments = filePath.split("/").filter(Boolean);
const fileName = segments.pop() ?? filePath;
const parentDir = segments.pop();
return parentDir ? `${parentDir}/${fileName}` : fileName;
}

function parseAttrs(raw: string): Record<string, string> {
const attrs: Record<string, string> = {};
for (const match of raw.matchAll(ATTR_REGEX)) {
attrs[match[1]] = unescapeXmlAttr(match[2]);
}
return attrs;
}

function chipFromTag(tag: string, rawAttrs: string): MentionChip | null {
const attrs = parseAttrs(rawAttrs);
switch (tag) {
case "file": {
const path = attrs.path;
if (!path) return null;
return { type: "file", id: path, label: deriveFileLabel(path) };
}
case "error":
case "experiment":
case "insight":
case "feature_flag": {
const id = attrs.id;
if (!id) return null;
return { type: tag, id, label: id };
}
case "github_issue": {
const number = attrs.number ?? "";
const title = attrs.title ?? "";
const url = attrs.url ?? "";
if (!number && !url) return null;
const label = title ? `#${number} - ${title}` : `#${number}`;
return { type: "github_issue", id: url, label };
}
default:
return null;
}
}

export function xmlToContent(xml: string): EditorContent {
const segments: EditorContent["segments"] = [];
let lastIndex = 0;

for (const match of xml.matchAll(CHIP_TAG_REGEX)) {
const matchIndex = match.index ?? 0;
const chip = chipFromTag(match[1], match[2] ?? "");
if (!chip) continue;

if (matchIndex > lastIndex) {
segments.push({ type: "text", text: xml.slice(lastIndex, matchIndex) });
}
segments.push({ type: "chip", chip });
lastIndex = matchIndex + match[0].length;
}

if (lastIndex < xml.length) {
segments.push({ type: "text", text: xml.slice(lastIndex) });
}

if (segments.length === 0) {
segments.push({ type: "text", text: xml });
}

return { segments };
}

export function isContentEmpty(
content: EditorContent | null | string,
): boolean {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { tryExecuteCodeCommand } from "@features/message-editor/commands";
import { useDraftStore } from "@features/message-editor/stores/draftStore";
import { xmlToContent } from "@features/message-editor/utils/content";
import { useTaskViewed } from "@features/sidebar/hooks/useTaskViewed";
import { trpcClient } from "@renderer/trpc/client";
import type { Task } from "@shared/types";
Expand Down Expand Up @@ -78,9 +79,7 @@ export function useSessionCallbacks({
log.info("Prompt cancelled", { success: result });

if (queuedContent) {
setPendingContent(taskId, {
segments: [{ type: "text", text: queuedContent }],
});
setPendingContent(taskId, xmlToContent(queuedContent));
}
requestFocus(taskId);
}, [taskId, setPendingContent, requestFocus]);
Expand Down
Loading