@stigmer/react 3.0.9-dev.20260615150714 → 3.0.9-dev.20260616060535
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/execution/ArtifactContentRenderer.d.ts.map +1 -1
- package/execution/ArtifactContentRenderer.js +7 -3
- package/execution/ArtifactContentRenderer.js.map +1 -1
- package/execution/ArtifactPreviewModal.d.ts +3 -3
- package/execution/ArtifactPreviewModal.js +4 -4
- package/execution/ArtifactPreviewModal.js.map +1 -1
- package/execution/MessageEntry.d.ts.map +1 -1
- package/execution/MessageEntry.js +7 -3
- package/execution/MessageEntry.js.map +1 -1
- package/execution/PlanArtifactCard.d.ts +25 -14
- package/execution/PlanArtifactCard.d.ts.map +1 -1
- package/execution/PlanArtifactCard.js +25 -10
- package/execution/PlanArtifactCard.js.map +1 -1
- package/execution/PlanCompletionCard.d.ts +12 -9
- package/execution/PlanCompletionCard.d.ts.map +1 -1
- package/execution/PlanCompletionCard.js +14 -9
- package/execution/PlanCompletionCard.js.map +1 -1
- package/execution/use-build-from-plan-hotkey.d.ts +19 -0
- package/execution/use-build-from-plan-hotkey.d.ts.map +1 -0
- package/execution/use-build-from-plan-hotkey.js +30 -0
- package/execution/use-build-from-plan-hotkey.js.map +1 -0
- package/internal/code-highlight.d.ts +24 -0
- package/internal/code-highlight.d.ts.map +1 -0
- package/internal/code-highlight.js +115 -0
- package/internal/code-highlight.js.map +1 -0
- package/internal/markdown-components.d.ts +18 -0
- package/internal/markdown-components.d.ts.map +1 -1
- package/internal/markdown-components.js +46 -2
- package/internal/markdown-components.js.map +1 -1
- package/package.json +7 -4
- package/src/execution/ArtifactContentRenderer.tsx +11 -2
- package/src/execution/ArtifactPreviewModal.tsx +7 -7
- package/src/execution/MessageEntry.tsx +14 -3
- package/src/execution/PlanArtifactCard.tsx +60 -51
- package/src/execution/PlanCompletionCard.tsx +20 -13
- package/src/execution/__tests__/ArtifactContentRenderer.test.tsx +62 -0
- package/src/execution/__tests__/ArtifactPreviewModal.test.tsx +6 -6
- package/src/execution/__tests__/MessageThread.test.tsx +3 -3
- package/src/execution/__tests__/PlanArtifactCard.test.tsx +120 -31
- package/src/execution/__tests__/PlanCompletionCard.test.tsx +31 -2
- package/src/execution/__tests__/message-entry.test.tsx +43 -0
- package/src/execution/use-build-from-plan-hotkey.ts +39 -0
- package/src/internal/__tests__/code-highlight.test.tsx +59 -0
- package/src/internal/__tests__/markdown-components.test.tsx +119 -0
- package/src/internal/code-highlight.tsx +120 -0
- package/src/internal/markdown-components.tsx +56 -3
- package/src/session/inspector/__tests__/ArtifactsTab.test.tsx +7 -7
- package/src/styles.css +88 -0
- package/styles.css +1 -1
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
+
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
|
3
|
+
import { ArtifactContentRenderer } from "../ArtifactContentRenderer";
|
|
4
|
+
|
|
5
|
+
afterEach(cleanup);
|
|
6
|
+
|
|
7
|
+
describe("ArtifactContentRenderer — markdown", () => {
|
|
8
|
+
const wrappedPlan = "```markdown\n# Plan\n\n1. First\n2. Second\n```";
|
|
9
|
+
|
|
10
|
+
it("unwraps a model-wrapped ```markdown plan in the Rendered view", () => {
|
|
11
|
+
const { container } = render(
|
|
12
|
+
<ArtifactContentRenderer content={wrappedPlan} fileName="plan.md" />,
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
// Rendered is the default tab: the heading/list render as real elements and
|
|
16
|
+
// the plan is not trapped inside a single <pre>.
|
|
17
|
+
expect(container.querySelector("h1")).not.toBeNull();
|
|
18
|
+
expect(container.querySelector("ol")).not.toBeNull();
|
|
19
|
+
expect(container.querySelector("pre")).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("keeps the Source view byte-faithful to the stored artifact", () => {
|
|
23
|
+
const { container } = render(
|
|
24
|
+
<ArtifactContentRenderer content={wrappedPlan} fileName="plan.md" />,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
fireEvent.click(screen.getByRole("tab", { name: "Source" }));
|
|
28
|
+
|
|
29
|
+
// Source shows the raw bytes — including the enclosing fence we hid in the
|
|
30
|
+
// Rendered view — so a download/copy stays faithful to what the agent wrote.
|
|
31
|
+
const pre = container.querySelector("pre");
|
|
32
|
+
expect(pre).not.toBeNull();
|
|
33
|
+
expect(pre!.textContent).toContain("```markdown");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("leaves an already-rich markdown plan unchanged", () => {
|
|
37
|
+
const { container } = render(
|
|
38
|
+
<ArtifactContentRenderer
|
|
39
|
+
content={"# Plan\n\n- only step"}
|
|
40
|
+
fileName="plan.md"
|
|
41
|
+
/>,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
expect(container.querySelector("h1")).not.toBeNull();
|
|
45
|
+
expect(container.querySelector("pre")).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("syntax-highlights fenced code blocks via the shared seam", () => {
|
|
49
|
+
const md = "# Notes\n\n```go\nfunc main() {}\n```\n";
|
|
50
|
+
const { container } = render(
|
|
51
|
+
<ArtifactContentRenderer content={md} fileName="notes.md" />,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// Same shared `MARKDOWN_COMPONENTS.code` override the chat stream uses, so
|
|
55
|
+
// the react-markdown artifact path colorizes code identically.
|
|
56
|
+
const code = container.querySelector("code.hljs");
|
|
57
|
+
expect(code).not.toBeNull();
|
|
58
|
+
expect(
|
|
59
|
+
container.querySelectorAll('span[class*="hljs-"]').length,
|
|
60
|
+
).toBeGreaterThan(0);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -27,8 +27,8 @@ function withStigmer(children: ReactNode) {
|
|
|
27
27
|
|
|
28
28
|
afterEach(cleanup);
|
|
29
29
|
|
|
30
|
-
describe("ArtifactPreviewContent —
|
|
31
|
-
it("renders
|
|
30
|
+
describe("ArtifactPreviewContent — Build from plan action", () => {
|
|
31
|
+
it("renders a 'Build from plan' button only when onImplement is provided", () => {
|
|
32
32
|
const { rerender } = render(
|
|
33
33
|
withStigmer(
|
|
34
34
|
<ArtifactPreviewContent
|
|
@@ -40,7 +40,7 @@ describe("ArtifactPreviewContent — Implement action", () => {
|
|
|
40
40
|
/>,
|
|
41
41
|
),
|
|
42
42
|
);
|
|
43
|
-
expect(screen.queryByText("
|
|
43
|
+
expect(screen.queryByText("Build from plan")).toBeNull();
|
|
44
44
|
|
|
45
45
|
rerender(
|
|
46
46
|
withStigmer(
|
|
@@ -54,10 +54,10 @@ describe("ArtifactPreviewContent — Implement action", () => {
|
|
|
54
54
|
/>,
|
|
55
55
|
),
|
|
56
56
|
);
|
|
57
|
-
expect(screen.getByText("
|
|
57
|
+
expect(screen.getByText("Build from plan")).toBeTruthy();
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
it("calls onImplement then closes the modal when
|
|
60
|
+
it("calls onImplement then closes the modal when 'Build from plan' is clicked", () => {
|
|
61
61
|
const calls: string[] = [];
|
|
62
62
|
const onImplement = vi.fn(() => calls.push("implement"));
|
|
63
63
|
const onClose = vi.fn(() => calls.push("close"));
|
|
@@ -75,7 +75,7 @@ describe("ArtifactPreviewContent — Implement action", () => {
|
|
|
75
75
|
),
|
|
76
76
|
);
|
|
77
77
|
|
|
78
|
-
fireEvent.click(screen.getByText("
|
|
78
|
+
fireEvent.click(screen.getByText("Build from plan"));
|
|
79
79
|
|
|
80
80
|
expect(onImplement).toHaveBeenCalledTimes(1);
|
|
81
81
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
@@ -476,10 +476,10 @@ describe("MessageThread", () => {
|
|
|
476
476
|
/>,
|
|
477
477
|
);
|
|
478
478
|
|
|
479
|
-
const
|
|
480
|
-
expect(
|
|
479
|
+
const buildBtn = screen.getByRole("button", { name: /build from plan/i });
|
|
480
|
+
expect(buildBtn).toBeTruthy();
|
|
481
481
|
|
|
482
|
-
fireEvent.click(
|
|
482
|
+
fireEvent.click(buildBtn);
|
|
483
483
|
expect(onBuildFromPlan).toHaveBeenCalledOnce();
|
|
484
484
|
});
|
|
485
485
|
});
|
|
@@ -17,36 +17,49 @@ const planArtifact = create(ExecutionArtifactSchema, {
|
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
/** Modal content fetches artifact text — keep it pending so nothing rejects. */
|
|
20
|
-
function createStigmerMock(): Stigmer {
|
|
20
|
+
function createStigmerMock(): { stigmer: Stigmer; getArtifactContent: ReturnType<typeof vi.fn> } {
|
|
21
|
+
const getArtifactContent = vi.fn().mockReturnValue(new Promise(() => {}));
|
|
21
22
|
return {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
},
|
|
25
|
-
|
|
23
|
+
stigmer: {
|
|
24
|
+
agentExecution: { getArtifactContent },
|
|
25
|
+
} as unknown as Stigmer,
|
|
26
|
+
getArtifactContent,
|
|
27
|
+
};
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
function withStigmer(children: ReactNode) {
|
|
30
|
+
function withStigmer(children: ReactNode, stigmer: Stigmer) {
|
|
29
31
|
return (
|
|
30
|
-
<StigmerContext.Provider value={
|
|
31
|
-
{children}
|
|
32
|
-
</StigmerContext.Provider>
|
|
32
|
+
<StigmerContext.Provider value={stigmer}>{children}</StigmerContext.Provider>
|
|
33
33
|
);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
afterEach(cleanup);
|
|
37
37
|
|
|
38
|
-
describe("PlanArtifactCard", () => {
|
|
39
|
-
it("
|
|
38
|
+
describe("PlanArtifactCard — action surface", () => {
|
|
39
|
+
it("is an accessible region labelled as the plan's actions, with name + size", () => {
|
|
40
40
|
render(
|
|
41
41
|
<PlanArtifactCard executionId="aex_1" artifact={planArtifact} org="acme" />,
|
|
42
42
|
);
|
|
43
43
|
|
|
44
|
-
const region = screen.getByRole("region", { name: "Plan
|
|
45
|
-
expect(region.textContent).toContain("Plan
|
|
46
|
-
expect(region.textContent).toContain("
|
|
44
|
+
const region = screen.getByRole("region", { name: "Plan actions" });
|
|
45
|
+
expect(region.textContent).toContain("Plan");
|
|
46
|
+
expect(region.textContent).toContain("KB");
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
-
it("
|
|
49
|
+
it("does NOT render the plan content (the message above is the document)", () => {
|
|
50
|
+
const { stigmer, getArtifactContent } = createStigmerMock();
|
|
51
|
+
render(
|
|
52
|
+
withStigmer(
|
|
53
|
+
<PlanArtifactCard executionId="aex_1" artifact={planArtifact} org="acme" />,
|
|
54
|
+
stigmer,
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
// The card is a pure action surface — it must not fetch the plan body until
|
|
58
|
+
// the user explicitly opens the full preview.
|
|
59
|
+
expect(getArtifactContent).not.toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("offers exactly one primary 'Build from plan' action that calls onImplement", () => {
|
|
50
63
|
const onImplement = vi.fn();
|
|
51
64
|
render(
|
|
52
65
|
<PlanArtifactCard
|
|
@@ -57,18 +70,18 @@ describe("PlanArtifactCard", () => {
|
|
|
57
70
|
/>,
|
|
58
71
|
);
|
|
59
72
|
|
|
60
|
-
fireEvent.click(screen.getByText("
|
|
73
|
+
fireEvent.click(screen.getByText("Build from plan"));
|
|
61
74
|
expect(onImplement).toHaveBeenCalledTimes(1);
|
|
62
75
|
});
|
|
63
76
|
|
|
64
|
-
it("hides
|
|
77
|
+
it("hides 'Build from plan' when onImplement is not provided", () => {
|
|
65
78
|
render(
|
|
66
79
|
<PlanArtifactCard executionId="aex_1" artifact={planArtifact} org="acme" />,
|
|
67
80
|
);
|
|
68
|
-
expect(screen.queryByText("
|
|
81
|
+
expect(screen.queryByText("Build from plan")).toBeNull();
|
|
69
82
|
});
|
|
70
83
|
|
|
71
|
-
it("disables
|
|
84
|
+
it("disables 'Build from plan' when disabled", () => {
|
|
72
85
|
const onImplement = vi.fn();
|
|
73
86
|
render(
|
|
74
87
|
<PlanArtifactCard
|
|
@@ -80,7 +93,7 @@ describe("PlanArtifactCard", () => {
|
|
|
80
93
|
/>,
|
|
81
94
|
);
|
|
82
95
|
|
|
83
|
-
const button = screen.getByText("
|
|
96
|
+
const button = screen.getByText("Build from plan").closest("button")!;
|
|
84
97
|
expect(button.disabled).toBe(true);
|
|
85
98
|
fireEvent.click(button);
|
|
86
99
|
expect(onImplement).not.toHaveBeenCalled();
|
|
@@ -93,30 +106,106 @@ describe("PlanArtifactCard", () => {
|
|
|
93
106
|
const link = screen.getByText("Download").closest("a")!;
|
|
94
107
|
expect(link.getAttribute("href")).toBe("https://example.test/plan.md");
|
|
95
108
|
});
|
|
109
|
+
});
|
|
96
110
|
|
|
97
|
-
|
|
111
|
+
describe("PlanArtifactCard — Cmd/Ctrl+Enter accelerator (card-scoped)", () => {
|
|
112
|
+
it("fires onImplement on Cmd+Enter from within the card", () => {
|
|
113
|
+
const onImplement = vi.fn();
|
|
114
|
+
render(
|
|
115
|
+
<PlanArtifactCard
|
|
116
|
+
executionId="aex_1"
|
|
117
|
+
artifact={planArtifact}
|
|
118
|
+
org="acme"
|
|
119
|
+
onImplement={onImplement}
|
|
120
|
+
/>,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const region = screen.getByRole("region", { name: "Plan actions" });
|
|
124
|
+
fireEvent.keyDown(region, { key: "Enter", metaKey: true });
|
|
125
|
+
expect(onImplement).toHaveBeenCalledTimes(1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("fires onImplement on Ctrl+Enter", () => {
|
|
129
|
+
const onImplement = vi.fn();
|
|
130
|
+
render(
|
|
131
|
+
<PlanArtifactCard
|
|
132
|
+
executionId="aex_1"
|
|
133
|
+
artifact={planArtifact}
|
|
134
|
+
org="acme"
|
|
135
|
+
onImplement={onImplement}
|
|
136
|
+
/>,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const region = screen.getByRole("region", { name: "Plan actions" });
|
|
140
|
+
fireEvent.keyDown(region, { key: "Enter", ctrlKey: true });
|
|
141
|
+
expect(onImplement).toHaveBeenCalledTimes(1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("ignores a plain Enter (no modifier) so it never hijacks typing", () => {
|
|
145
|
+
const onImplement = vi.fn();
|
|
146
|
+
render(
|
|
147
|
+
<PlanArtifactCard
|
|
148
|
+
executionId="aex_1"
|
|
149
|
+
artifact={planArtifact}
|
|
150
|
+
org="acme"
|
|
151
|
+
onImplement={onImplement}
|
|
152
|
+
/>,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const region = screen.getByRole("region", { name: "Plan actions" });
|
|
156
|
+
fireEvent.keyDown(region, { key: "Enter" });
|
|
157
|
+
expect(onImplement).not.toHaveBeenCalled();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("does not fire when disabled", () => {
|
|
161
|
+
const onImplement = vi.fn();
|
|
162
|
+
render(
|
|
163
|
+
<PlanArtifactCard
|
|
164
|
+
executionId="aex_1"
|
|
165
|
+
artifact={planArtifact}
|
|
166
|
+
org="acme"
|
|
167
|
+
onImplement={onImplement}
|
|
168
|
+
disabled
|
|
169
|
+
/>,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const region = screen.getByRole("region", { name: "Plan actions" });
|
|
173
|
+
fireEvent.keyDown(region, { key: "Enter", metaKey: true });
|
|
174
|
+
expect(onImplement).not.toHaveBeenCalled();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("PlanArtifactCard — Open full (org-gated preview)", () => {
|
|
179
|
+
it("opens the shared preview modal when 'Open full' is clicked", () => {
|
|
180
|
+
const { stigmer } = createStigmerMock();
|
|
98
181
|
render(
|
|
99
182
|
withStigmer(
|
|
100
|
-
<PlanArtifactCard
|
|
101
|
-
|
|
102
|
-
artifact={planArtifact}
|
|
103
|
-
org="acme"
|
|
104
|
-
/>,
|
|
183
|
+
<PlanArtifactCard executionId="aex_1" artifact={planArtifact} org="acme" />,
|
|
184
|
+
stigmer,
|
|
105
185
|
),
|
|
106
186
|
);
|
|
107
187
|
|
|
108
|
-
// No dialog before the user
|
|
188
|
+
// No dialog before the user opens it.
|
|
109
189
|
expect(document.querySelector("dialog")).toBeNull();
|
|
110
190
|
|
|
111
|
-
fireEvent.click(screen.getByText("
|
|
191
|
+
fireEvent.click(screen.getByText("Open full"));
|
|
112
192
|
|
|
113
193
|
const dialog = document.querySelector("dialog");
|
|
114
194
|
expect(dialog).toBeTruthy();
|
|
115
195
|
expect(dialog!.getAttribute("aria-label")).toBe("Preview plan.md");
|
|
116
196
|
});
|
|
117
197
|
|
|
118
|
-
it("hides
|
|
119
|
-
|
|
120
|
-
|
|
198
|
+
it("hides 'Open full' when org is absent (modal needs org), keeping Build + Download", () => {
|
|
199
|
+
const onImplement = vi.fn();
|
|
200
|
+
render(
|
|
201
|
+
<PlanArtifactCard
|
|
202
|
+
executionId="aex_1"
|
|
203
|
+
artifact={planArtifact}
|
|
204
|
+
onImplement={onImplement}
|
|
205
|
+
/>,
|
|
206
|
+
);
|
|
207
|
+
expect(screen.queryByText("Open full")).toBeNull();
|
|
208
|
+
expect(screen.queryByText("Build from plan")).not.toBeNull();
|
|
209
|
+
expect(screen.queryByText("Download")).not.toBeNull();
|
|
121
210
|
});
|
|
122
211
|
});
|
|
@@ -3,7 +3,7 @@ import { render, fireEvent, cleanup } from "@testing-library/react";
|
|
|
3
3
|
import { PlanCompletionCard } from "../PlanCompletionCard";
|
|
4
4
|
|
|
5
5
|
describe("PlanCompletionCard", () => {
|
|
6
|
-
it("renders the card with status text and
|
|
6
|
+
it("renders the card with status text and a 'Build from plan' button", () => {
|
|
7
7
|
const { container } = render(<PlanCompletionCard onImplement={() => {}} />);
|
|
8
8
|
|
|
9
9
|
const root = container.firstElementChild as HTMLElement;
|
|
@@ -13,12 +13,41 @@ describe("PlanCompletionCard", () => {
|
|
|
13
13
|
|
|
14
14
|
const button = root.querySelector("button");
|
|
15
15
|
expect(button).toBeTruthy();
|
|
16
|
-
expect(button!.textContent).toContain("
|
|
16
|
+
expect(button!.textContent).toContain("Build from plan");
|
|
17
17
|
|
|
18
18
|
expect(root.textContent).toContain("Plan complete");
|
|
19
19
|
cleanup();
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
+
it("fires onImplement on Cmd/Ctrl+Enter from within the card", () => {
|
|
23
|
+
const onImplement = vi.fn();
|
|
24
|
+
const { container } = render(
|
|
25
|
+
<PlanCompletionCard onImplement={onImplement} />,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const root = container.firstElementChild as HTMLElement;
|
|
29
|
+
fireEvent.keyDown(root, { key: "Enter", metaKey: true });
|
|
30
|
+
fireEvent.keyDown(root, { key: "Enter", ctrlKey: true });
|
|
31
|
+
expect(onImplement).toHaveBeenCalledTimes(2);
|
|
32
|
+
|
|
33
|
+
// A plain Enter must not trigger it.
|
|
34
|
+
fireEvent.keyDown(root, { key: "Enter" });
|
|
35
|
+
expect(onImplement).toHaveBeenCalledTimes(2);
|
|
36
|
+
cleanup();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("does not fire the accelerator when disabled", () => {
|
|
40
|
+
const onImplement = vi.fn();
|
|
41
|
+
const { container } = render(
|
|
42
|
+
<PlanCompletionCard onImplement={onImplement} disabled />,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const root = container.firstElementChild as HTMLElement;
|
|
46
|
+
fireEvent.keyDown(root, { key: "Enter", metaKey: true });
|
|
47
|
+
expect(onImplement).not.toHaveBeenCalled();
|
|
48
|
+
cleanup();
|
|
49
|
+
});
|
|
50
|
+
|
|
22
51
|
it("calls onImplement when the button is clicked", () => {
|
|
23
52
|
const onImplement = vi.fn();
|
|
24
53
|
const { container } = render(<PlanCompletionCard onImplement={onImplement} />);
|
|
@@ -126,6 +126,49 @@ describe("MessageEntry — AI messages (Streamdown)", () => {
|
|
|
126
126
|
const prose = container.querySelector(".stgm-prose");
|
|
127
127
|
expect(prose).not.toBeNull();
|
|
128
128
|
});
|
|
129
|
+
|
|
130
|
+
it("renders a model-wrapped ```markdown plan as rich markdown, not a code block", () => {
|
|
131
|
+
const msg = makeMessage(
|
|
132
|
+
MessageType.MESSAGE_AI,
|
|
133
|
+
"```markdown\n# Plan\n\n1. First step\n2. Second step\n```",
|
|
134
|
+
);
|
|
135
|
+
const { container } = render(<MessageEntry message={msg} />);
|
|
136
|
+
|
|
137
|
+
const article = queryArticle(container, "AI response");
|
|
138
|
+
// The enclosing fence is unwrapped: the heading and list render as elements,
|
|
139
|
+
// and the whole plan is NOT trapped inside a single <pre>.
|
|
140
|
+
expect(article!.querySelector("h1")).not.toBeNull();
|
|
141
|
+
expect(article!.querySelector("ol")).not.toBeNull();
|
|
142
|
+
expect(article!.querySelector("pre")).toBeNull();
|
|
143
|
+
expect(article!.textContent).toContain("Plan");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("still renders a genuine ```js code block as a code block", () => {
|
|
147
|
+
const msg = makeMessage(
|
|
148
|
+
MessageType.MESSAGE_AI,
|
|
149
|
+
"```js\nconsole.log('hi');\n```",
|
|
150
|
+
);
|
|
151
|
+
const { container } = render(<MessageEntry message={msg} />);
|
|
152
|
+
|
|
153
|
+
const article = queryArticle(container, "AI response");
|
|
154
|
+
expect(article!.querySelector("pre")).not.toBeNull();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("syntax-highlights fenced code in the chat stream (Streamdown path)", () => {
|
|
158
|
+
const msg = makeMessage(
|
|
159
|
+
MessageType.MESSAGE_AI,
|
|
160
|
+
"```go\nfunc main() {}\n```",
|
|
161
|
+
);
|
|
162
|
+
const { container } = render(<MessageEntry message={msg} />);
|
|
163
|
+
|
|
164
|
+
const article = queryArticle(container, "AI response");
|
|
165
|
+
// Streamdown routes fenced code through the shared MARKDOWN_COMPONENTS.code
|
|
166
|
+
// override, so chat highlights identically to the artifact viewer.
|
|
167
|
+
expect(article!.querySelector("code.hljs")).not.toBeNull();
|
|
168
|
+
expect(
|
|
169
|
+
article!.querySelectorAll('span[class*="hljs-"]').length,
|
|
170
|
+
).toBeGreaterThan(0);
|
|
171
|
+
});
|
|
129
172
|
});
|
|
130
173
|
|
|
131
174
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, type KeyboardEvent } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Card-scoped `Cmd/Ctrl+Enter` accelerator for the "Build from plan" action.
|
|
7
|
+
*
|
|
8
|
+
* Returns an `onKeyDown` handler for a plan card's root element. Because it is
|
|
9
|
+
* attached to the card (not `window`), it fires only when the keystroke
|
|
10
|
+
* originates from within the card — so the SDK never installs a global listener
|
|
11
|
+
* that could hijack a host application's keyboard shortcuts (an embeddable-a11y
|
|
12
|
+
* requirement). The card's primary button stays natively activatable with
|
|
13
|
+
* Enter/Space when focused; this adds the power-user accelerator on top.
|
|
14
|
+
*
|
|
15
|
+
* No-op when there is no action wired or the action is disabled.
|
|
16
|
+
*
|
|
17
|
+
* Shared by {@link PlanArtifactCard} and {@link PlanCompletionCard} so the
|
|
18
|
+
* accelerator's behavior lives in exactly one place and cannot drift between
|
|
19
|
+
* the two cards.
|
|
20
|
+
*/
|
|
21
|
+
export function useBuildFromPlanHotkey(
|
|
22
|
+
onImplement: (() => void) | undefined,
|
|
23
|
+
disabled: boolean | undefined,
|
|
24
|
+
): (event: KeyboardEvent<HTMLElement>) => void {
|
|
25
|
+
return useCallback(
|
|
26
|
+
(event) => {
|
|
27
|
+
if (
|
|
28
|
+
onImplement &&
|
|
29
|
+
!disabled &&
|
|
30
|
+
event.key === "Enter" &&
|
|
31
|
+
(event.metaKey || event.ctrlKey)
|
|
32
|
+
) {
|
|
33
|
+
event.preventDefault();
|
|
34
|
+
onImplement();
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
[onImplement, disabled],
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
+
import { render, cleanup } from "@testing-library/react";
|
|
3
|
+
import { resolveLanguage, highlightToReact } from "../code-highlight";
|
|
4
|
+
|
|
5
|
+
afterEach(cleanup);
|
|
6
|
+
|
|
7
|
+
describe("resolveLanguage", () => {
|
|
8
|
+
it.each([
|
|
9
|
+
["a registered name", "go", "go"],
|
|
10
|
+
["uppercase is normalized", "GO", "go"],
|
|
11
|
+
["surrounding whitespace is trimmed", " yaml ", "yaml"],
|
|
12
|
+
["a registered alias (ts → typescript)", "ts", "ts"],
|
|
13
|
+
["a registered alias (tsx)", "tsx", "tsx"],
|
|
14
|
+
["a registered alias (html → xml)", "html", "html"],
|
|
15
|
+
["a registered alias (sh → bash)", "sh", "sh"],
|
|
16
|
+
])("resolves %s", (_label, input, expected) => {
|
|
17
|
+
expect(resolveLanguage(input)).toBe(expected);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it.each([
|
|
21
|
+
["an unregistered language", "hcl"],
|
|
22
|
+
["a nonsense language", "klingon"],
|
|
23
|
+
["an empty string", ""],
|
|
24
|
+
["whitespace only", " "],
|
|
25
|
+
["undefined", undefined],
|
|
26
|
+
])("returns null for %s", (_label, input) => {
|
|
27
|
+
expect(resolveLanguage(input)).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("highlightToReact", () => {
|
|
32
|
+
it("produces hljs token spans for a known language", () => {
|
|
33
|
+
const node = highlightToReact("func main() {}", "go");
|
|
34
|
+
expect(node).not.toBeNull();
|
|
35
|
+
|
|
36
|
+
const { container } = render(<code>{node}</code>);
|
|
37
|
+
// `func` is a Go keyword → at least one tokenized span must appear.
|
|
38
|
+
expect(
|
|
39
|
+
container.querySelectorAll('span[class*="hljs-"]').length,
|
|
40
|
+
).toBeGreaterThan(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("highlights via an alias (ts)", () => {
|
|
44
|
+
const node = highlightToReact("const x: number = 1;", "ts");
|
|
45
|
+
expect(node).not.toBeNull();
|
|
46
|
+
|
|
47
|
+
const { container } = render(<code>{node}</code>);
|
|
48
|
+
expect(
|
|
49
|
+
container.querySelectorAll('span[class*="hljs-"]').length,
|
|
50
|
+
).toBeGreaterThan(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it.each([
|
|
54
|
+
["an unregistered language", "hcl"],
|
|
55
|
+
["no language", undefined],
|
|
56
|
+
])("returns null for %s (caller falls back to flat)", (_label, language) => {
|
|
57
|
+
expect(highlightToReact("anything at all", language)).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
+
import { render, cleanup } from "@testing-library/react";
|
|
3
|
+
import type { ComponentType, ReactNode } from "react";
|
|
4
|
+
import {
|
|
5
|
+
MARKDOWN_COMPONENTS,
|
|
6
|
+
unwrapEnclosingMarkdownFence,
|
|
7
|
+
} from "../markdown-components";
|
|
8
|
+
|
|
9
|
+
afterEach(cleanup);
|
|
10
|
+
|
|
11
|
+
/** The shared `code` override, typed for direct rendering in tests. */
|
|
12
|
+
const CodeComponent = MARKDOWN_COMPONENTS.code as ComponentType<{
|
|
13
|
+
className?: string;
|
|
14
|
+
children?: ReactNode;
|
|
15
|
+
}>;
|
|
16
|
+
|
|
17
|
+
describe("unwrapEnclosingMarkdownFence", () => {
|
|
18
|
+
describe("unwraps a whole-message markdown fence", () => {
|
|
19
|
+
it.each([
|
|
20
|
+
["```markdown tag", "```markdown\n# Plan\n- step\n```", "# Plan\n- step"],
|
|
21
|
+
["```md tag", "```md\n# Plan\n```", "# Plan"],
|
|
22
|
+
["case-insensitive tag", "```Markdown\n# Plan\n```", "# Plan"],
|
|
23
|
+
["uppercase md", "```MD\n# Plan\n```", "# Plan"],
|
|
24
|
+
[
|
|
25
|
+
"tilde-free body with inner code fence preserved",
|
|
26
|
+
"```markdown\n# Plan\n```ts\nconst x = 1;\n```\n```",
|
|
27
|
+
"# Plan\n```ts\nconst x = 1;\n```",
|
|
28
|
+
],
|
|
29
|
+
[
|
|
30
|
+
"surrounding whitespace is ignored",
|
|
31
|
+
"\n\n```markdown\n# Plan\n```\n\n",
|
|
32
|
+
"# Plan",
|
|
33
|
+
],
|
|
34
|
+
[
|
|
35
|
+
"longer-than-three backtick fence",
|
|
36
|
+
"````markdown\n# Plan\n````",
|
|
37
|
+
"# Plan",
|
|
38
|
+
],
|
|
39
|
+
])("%s", (_label, input, expected) => {
|
|
40
|
+
expect(unwrapEnclosingMarkdownFence(input)).toBe(expected);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("leaves everything else untouched (returns the original)", () => {
|
|
45
|
+
it.each([
|
|
46
|
+
["plain markdown (no fence)", "# Plan\n\n- step one\n- step two"],
|
|
47
|
+
["a bare ``` fence (ambiguous — could be code)", "```\n# Plan\n```"],
|
|
48
|
+
["a language-tagged code block", "```js\nconsole.log('hi');\n```"],
|
|
49
|
+
["an unclosed fence (mid-stream)", "```markdown\n# Plan in progr"],
|
|
50
|
+
["trailing content after the fence", "```markdown\n# Plan\n```\nthen more"],
|
|
51
|
+
["leading content before the fence", "intro\n```markdown\n# Plan\n```"],
|
|
52
|
+
["a richer info string than markdown", "```markdown title\n# Plan\n```"],
|
|
53
|
+
["an empty fenced block", "```markdown\n```"],
|
|
54
|
+
["plain prose", "Here is the plan you asked for."],
|
|
55
|
+
["empty string", ""],
|
|
56
|
+
])("%s", (_label, input) => {
|
|
57
|
+
expect(unwrapEnclosingMarkdownFence(input)).toBe(input);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("is idempotent — a once-unwrapped plan is left alone", () => {
|
|
62
|
+
const wrapped = "```markdown\n# Plan\n- a\n```";
|
|
63
|
+
const once = unwrapEnclosingMarkdownFence(wrapped);
|
|
64
|
+
expect(unwrapEnclosingMarkdownFence(once)).toBe(once);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("MARKDOWN_COMPONENTS.code (shared highlight seam)", () => {
|
|
69
|
+
it("syntax-highlights a fenced block with a known language", () => {
|
|
70
|
+
const { container } = render(
|
|
71
|
+
<CodeComponent className="language-go">
|
|
72
|
+
{"func main() {}"}
|
|
73
|
+
</CodeComponent>,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const code = container.querySelector("code");
|
|
77
|
+
expect(code).not.toBeNull();
|
|
78
|
+
expect(code!.className).toContain("hljs");
|
|
79
|
+
expect(code!.className).toContain("language-go");
|
|
80
|
+
expect(
|
|
81
|
+
container.querySelectorAll('span[class*="hljs-"]').length,
|
|
82
|
+
).toBeGreaterThan(0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("falls back to flat text for an unknown language (no token spans)", () => {
|
|
86
|
+
const { container } = render(
|
|
87
|
+
<CodeComponent className="language-hcl">
|
|
88
|
+
{'resource "x" {}'}
|
|
89
|
+
</CodeComponent>,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const code = container.querySelector("code");
|
|
93
|
+
expect(code).not.toBeNull();
|
|
94
|
+
expect(code!.textContent).toContain('resource "x" {}');
|
|
95
|
+
expect(container.querySelectorAll('span[class*="hljs-"]').length).toBe(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("falls back to flat for non-string children (e.g. a streaming caret)", () => {
|
|
99
|
+
const { container } = render(
|
|
100
|
+
<CodeComponent className="language-go">
|
|
101
|
+
<span data-testid="caret">x</span>
|
|
102
|
+
</CodeComponent>,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// Renders children untouched, no throw, no tokenization attempted.
|
|
106
|
+
expect(container.querySelector('[data-testid="caret"]')).not.toBeNull();
|
|
107
|
+
expect(container.querySelectorAll('span[class*="hljs-"]').length).toBe(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("leaves inline code unhighlighted and unboxed by hljs", () => {
|
|
111
|
+
const { container } = render(<CodeComponent>{"inlineToken"}</CodeComponent>);
|
|
112
|
+
|
|
113
|
+
const code = container.querySelector("code");
|
|
114
|
+
expect(code).not.toBeNull();
|
|
115
|
+
expect(code!.className).not.toContain("hljs");
|
|
116
|
+
expect(code!.className).toContain("bg-muted");
|
|
117
|
+
expect(code!.textContent).toBe("inlineToken");
|
|
118
|
+
});
|
|
119
|
+
});
|