Skip to content

Commit 7a93b1d

Browse files
authored
fix: restore queued chips on interrupt (#1699)
Closes #1488, parses serialized queued message back to file chips
1 parent 7b7f49e commit 7a93b1d

3 files changed

Lines changed: 218 additions & 4 deletions

File tree

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { describe, expect, it } from "vitest";
2+
import { contentToXml, type EditorContent, xmlToContent } from "./content";
3+
4+
describe("xmlToContent", () => {
5+
it("parses a file tag into a file chip", () => {
6+
const result = xmlToContent('<file path="src/foo/bar.ts" />');
7+
expect(result).toEqual({
8+
segments: [
9+
{
10+
type: "chip",
11+
chip: { type: "file", id: "src/foo/bar.ts", label: "foo/bar.ts" },
12+
},
13+
],
14+
});
15+
});
16+
17+
it("derives file label from the final path segment when no parent", () => {
18+
const result = xmlToContent('<file path="README.md" />');
19+
expect(result.segments).toEqual([
20+
{
21+
type: "chip",
22+
chip: { type: "file", id: "README.md", label: "README.md" },
23+
},
24+
]);
25+
});
26+
27+
it("unescapes XML attributes", () => {
28+
const result = xmlToContent('<file path="a/&quot;weird&quot;.ts" />');
29+
const segment = result.segments[0];
30+
expect(segment.type).toBe("chip");
31+
if (segment.type === "chip") {
32+
expect(segment.chip.id).toBe('a/"weird".ts');
33+
}
34+
});
35+
36+
it("parses github_issue tags with title", () => {
37+
const xml =
38+
'<github_issue number="42" title="Fix bug" url="https://github.com/org/repo/issues/42" />';
39+
expect(xmlToContent(xml).segments).toEqual([
40+
{
41+
type: "chip",
42+
chip: {
43+
type: "github_issue",
44+
id: "https://github.com/org/repo/issues/42",
45+
label: "#42 - Fix bug",
46+
},
47+
},
48+
]);
49+
});
50+
51+
it("parses github_issue tags without title", () => {
52+
const xml =
53+
'<github_issue number="7" url="https://github.com/org/repo/issues/7" />';
54+
const segment = xmlToContent(xml).segments[0];
55+
expect(segment.type).toBe("chip");
56+
if (segment.type === "chip") {
57+
expect(segment.chip.label).toBe("#7");
58+
}
59+
});
60+
61+
it.each([
62+
["error", "err-1"],
63+
["experiment", "exp-1"],
64+
["insight", "ins-1"],
65+
["feature_flag", "flag-1"],
66+
])("parses %s tag into a chip with id as label", (type, id) => {
67+
const xml = `<${type} id="${id}" />`;
68+
expect(xmlToContent(xml).segments).toEqual([
69+
{ type: "chip", chip: { type, id, label: id } },
70+
]);
71+
});
72+
73+
it("preserves surrounding text around chips", () => {
74+
const result = xmlToContent(
75+
'please review <file path="src/a.ts" /> and <file path="src/b.ts" />',
76+
);
77+
expect(result.segments).toEqual([
78+
{ type: "text", text: "please review " },
79+
{
80+
type: "chip",
81+
chip: { type: "file", id: "src/a.ts", label: "src/a.ts" },
82+
},
83+
{ type: "text", text: " and " },
84+
{
85+
type: "chip",
86+
chip: { type: "file", id: "src/b.ts", label: "src/b.ts" },
87+
},
88+
]);
89+
});
90+
91+
it("returns a single text segment when no tags are present", () => {
92+
expect(xmlToContent("just plain text").segments).toEqual([
93+
{ type: "text", text: "just plain text" },
94+
]);
95+
});
96+
97+
it("returns a single text segment for empty input", () => {
98+
expect(xmlToContent("").segments).toEqual([{ type: "text", text: "" }]);
99+
});
100+
101+
it("round-trips contentToXml for a mix of text and chips", () => {
102+
const content: EditorContent = {
103+
segments: [
104+
{ type: "text", text: "look at " },
105+
{
106+
type: "chip",
107+
chip: { type: "file", id: "apps/code/src/a.ts", label: "src/a.ts" },
108+
},
109+
{ type: "text", text: " and " },
110+
{
111+
type: "chip",
112+
chip: {
113+
type: "github_issue",
114+
id: "https://github.com/org/repo/issues/9",
115+
label: "#9 - Thing",
116+
},
117+
},
118+
],
119+
};
120+
121+
const xml = contentToXml(content);
122+
const parsed = xmlToContent(xml);
123+
expect(parsed.segments).toEqual([
124+
{ type: "text", text: "look at " },
125+
{
126+
type: "chip",
127+
chip: { type: "file", id: "apps/code/src/a.ts", label: "src/a.ts" },
128+
},
129+
{ type: "text", text: " and " },
130+
{
131+
type: "chip",
132+
chip: {
133+
type: "github_issue",
134+
id: "https://github.com/org/repo/issues/9",
135+
label: "#9 - Thing",
136+
},
137+
},
138+
]);
139+
});
140+
});

apps/code/src/renderer/features/message-editor/utils/content.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { escapeXmlAttr } from "@utils/xml";
1+
import { escapeXmlAttr, unescapeXmlAttr } from "@utils/xml";
22

33
export interface MentionChip {
44
type:
@@ -80,6 +80,81 @@ export function contentToXml(content: EditorContent): string {
8080
return parts.join("");
8181
}
8282

83+
const CHIP_TAG_REGEX =
84+
/<(file|error|experiment|insight|feature_flag|github_issue)\b([^>]*?)\s*\/>/g;
85+
const ATTR_REGEX = /(\w+)="([^"]*)"/g;
86+
87+
function deriveFileLabel(filePath: string): string {
88+
const segments = filePath.split("/").filter(Boolean);
89+
const fileName = segments.pop() ?? filePath;
90+
const parentDir = segments.pop();
91+
return parentDir ? `${parentDir}/${fileName}` : fileName;
92+
}
93+
94+
function parseAttrs(raw: string): Record<string, string> {
95+
const attrs: Record<string, string> = {};
96+
for (const match of raw.matchAll(ATTR_REGEX)) {
97+
attrs[match[1]] = unescapeXmlAttr(match[2]);
98+
}
99+
return attrs;
100+
}
101+
102+
function chipFromTag(tag: string, rawAttrs: string): MentionChip | null {
103+
const attrs = parseAttrs(rawAttrs);
104+
switch (tag) {
105+
case "file": {
106+
const path = attrs.path;
107+
if (!path) return null;
108+
return { type: "file", id: path, label: deriveFileLabel(path) };
109+
}
110+
case "error":
111+
case "experiment":
112+
case "insight":
113+
case "feature_flag": {
114+
const id = attrs.id;
115+
if (!id) return null;
116+
return { type: tag, id, label: id };
117+
}
118+
case "github_issue": {
119+
const number = attrs.number ?? "";
120+
const title = attrs.title ?? "";
121+
const url = attrs.url ?? "";
122+
if (!number && !url) return null;
123+
const label = title ? `#${number} - ${title}` : `#${number}`;
124+
return { type: "github_issue", id: url, label };
125+
}
126+
default:
127+
return null;
128+
}
129+
}
130+
131+
export function xmlToContent(xml: string): EditorContent {
132+
const segments: EditorContent["segments"] = [];
133+
let lastIndex = 0;
134+
135+
for (const match of xml.matchAll(CHIP_TAG_REGEX)) {
136+
const matchIndex = match.index ?? 0;
137+
const chip = chipFromTag(match[1], match[2] ?? "");
138+
if (!chip) continue;
139+
140+
if (matchIndex > lastIndex) {
141+
segments.push({ type: "text", text: xml.slice(lastIndex, matchIndex) });
142+
}
143+
segments.push({ type: "chip", chip });
144+
lastIndex = matchIndex + match[0].length;
145+
}
146+
147+
if (lastIndex < xml.length) {
148+
segments.push({ type: "text", text: xml.slice(lastIndex) });
149+
}
150+
151+
if (segments.length === 0) {
152+
segments.push({ type: "text", text: xml });
153+
}
154+
155+
return { segments };
156+
}
157+
83158
export function isContentEmpty(
84159
content: EditorContent | null | string,
85160
): boolean {

apps/code/src/renderer/features/sessions/hooks/useSessionCallbacks.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { tryExecuteCodeCommand } from "@features/message-editor/commands";
22
import { useDraftStore } from "@features/message-editor/stores/draftStore";
3+
import { xmlToContent } from "@features/message-editor/utils/content";
34
import { useTaskViewed } from "@features/sidebar/hooks/useTaskViewed";
45
import { trpcClient } from "@renderer/trpc/client";
56
import type { Task } from "@shared/types";
@@ -78,9 +79,7 @@ export function useSessionCallbacks({
7879
log.info("Prompt cancelled", { success: result });
7980

8081
if (queuedContent) {
81-
setPendingContent(taskId, {
82-
segments: [{ type: "text", text: queuedContent }],
83-
});
82+
setPendingContent(taskId, xmlToContent(queuedContent));
8483
}
8584
requestFocus(taskId);
8685
}, [taskId, setPendingContent, requestFocus]);

0 commit comments

Comments
 (0)