@stigmer/react 3.0.9-dev.20260615145121 → 3.0.9-dev.20260615153829
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/markdown-components.d.ts +18 -0
- package/internal/markdown-components.d.ts.map +1 -1
- package/internal/markdown-components.js +33 -0
- package/internal/markdown-components.js.map +1 -1
- package/package.json +4 -4
- package/portal-container.d.ts +9 -6
- package/portal-container.d.ts.map +1 -1
- package/portal-container.js +9 -6
- package/portal-container.js.map +1 -1
- package/provider.d.ts.map +1 -1
- package/provider.js +41 -19
- package/provider.js.map +1 -1
- package/src/__tests__/provider-theme-scope.test.tsx +94 -0
- 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 +47 -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 +27 -0
- package/src/execution/use-build-from-plan-hotkey.ts +39 -0
- package/src/internal/__tests__/markdown-components.test.tsx +53 -0
- package/src/internal/markdown-components.tsx +36 -0
- package/src/portal-container.ts +9 -6
- package/src/provider.tsx +70 -23
- package/src/session/inspector/__tests__/ArtifactsTab.test.tsx +7 -7
- package/styles.css +1 -1
|
@@ -5,6 +5,7 @@ import { cn } from "@stigmer/theme";
|
|
|
5
5
|
import type { ExecutionArtifact } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/artifact_pb";
|
|
6
6
|
import { ArtifactPreviewModal } from "./ArtifactPreviewModal";
|
|
7
7
|
import { formatArtifactSize } from "./artifact-utils";
|
|
8
|
+
import { useBuildFromPlanHotkey } from "./use-build-from-plan-hotkey";
|
|
8
9
|
|
|
9
10
|
/** Props for {@link PlanArtifactCard}. */
|
|
10
11
|
export interface PlanArtifactCardProps {
|
|
@@ -13,30 +14,41 @@ export interface PlanArtifactCardProps {
|
|
|
13
14
|
/** The published `plan.md` artifact (from `findPlanArtifact`). */
|
|
14
15
|
readonly artifact: ExecutionArtifact;
|
|
15
16
|
/**
|
|
16
|
-
* Organization slug. Required for the "
|
|
17
|
-
* {@link ArtifactPreviewModal} uses it for its detection/apply pipeline
|
|
18
|
-
* When omitted,
|
|
17
|
+
* Organization slug. Required for the "Open full" modal (the shared
|
|
18
|
+
* {@link ArtifactPreviewModal} uses it for its detection/apply pipeline and
|
|
19
|
+
* to fetch the content). When omitted, "Open full" is hidden — the prominent
|
|
20
|
+
* "Build from plan" action and "Download" remain.
|
|
19
21
|
*/
|
|
20
22
|
readonly org?: string;
|
|
21
|
-
/** Called when the user clicks "
|
|
23
|
+
/** Called when the user clicks "Build from plan". Hidden when omitted. */
|
|
22
24
|
readonly onImplement?: () => void;
|
|
23
|
-
/** Disables the
|
|
25
|
+
/** Disables the primary CTA (e.g., while an execution is active). */
|
|
24
26
|
readonly disabled?: boolean;
|
|
25
27
|
/** Additional CSS classes for the root element. */
|
|
26
28
|
readonly className?: string;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
+
* Action surface for a plan that a completed Plan-mode execution published as a
|
|
33
|
+
* `plan.md` artifact.
|
|
32
34
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* the
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
35
|
+
* It is rendered immediately below the plan message in the thread (which already
|
|
36
|
+
* shows the plan as rich markdown — the message renderer unwraps a model's
|
|
37
|
+
* enclosing markdown fence), so this card deliberately holds **no** plan
|
|
38
|
+
* content: the message is the document and this is its action bar. Rendering
|
|
39
|
+
* the plan a second time here would duplicate the same text the message and the
|
|
40
|
+
* "Open full" modal already show.
|
|
41
|
+
*
|
|
42
|
+
* The hierarchy is the point: one prominent, themeable primary action —
|
|
43
|
+
* **Build from plan** — with **Open full** and **Download** as subdued
|
|
44
|
+
* secondaries, so the next step is unmistakable. A small negative top margin
|
|
45
|
+
* pulls the bar against the plan message so the two read as a single
|
|
46
|
+
* first-class plan block.
|
|
47
|
+
*
|
|
48
|
+
* Kept as its own thread item (not merged into the message) on purpose: merging
|
|
49
|
+
* would change the streamed message item's identity at completion and remount
|
|
50
|
+
* the just-streamed plan. Adjacent-and-attached gets the unified look with no
|
|
51
|
+
* remount.
|
|
40
52
|
*
|
|
41
53
|
* All visual properties flow through `--stgm-*` tokens; the component is
|
|
42
54
|
* self-contained and embeddable.
|
|
@@ -50,59 +62,39 @@ export const PlanArtifactCard = memo(function PlanArtifactCard({
|
|
|
50
62
|
className,
|
|
51
63
|
}: PlanArtifactCardProps) {
|
|
52
64
|
const [previewOpen, setPreviewOpen] = useState(false);
|
|
65
|
+
const handleKeyDown = useBuildFromPlanHotkey(onImplement, disabled);
|
|
53
66
|
|
|
54
67
|
return (
|
|
55
68
|
<div
|
|
56
69
|
role="region"
|
|
57
|
-
aria-label="Plan
|
|
70
|
+
aria-label="Plan actions"
|
|
71
|
+
onKeyDown={handleKeyDown}
|
|
58
72
|
className={cn(
|
|
59
|
-
|
|
73
|
+
// `-mt-2` tightens the thread's `gap-4` so the bar attaches to the
|
|
74
|
+
// plan message above it, reading as one first-class plan block.
|
|
75
|
+
"mx-4 -mt-2 flex flex-wrap items-center gap-x-3 gap-y-2 rounded-md",
|
|
76
|
+
"border border-border-muted bg-muted-faint px-3 py-2",
|
|
60
77
|
className,
|
|
61
78
|
)}
|
|
62
79
|
>
|
|
63
|
-
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
Plan ready to review
|
|
69
|
-
</div>
|
|
70
|
-
<div className="text-xs tabular-nums text-muted-foreground">
|
|
71
|
-
{artifact.name} · {formatArtifactSize(artifact.sizeBytes)}
|
|
72
|
-
</div>
|
|
73
|
-
</div>
|
|
74
|
-
{onImplement && (
|
|
75
|
-
<button
|
|
76
|
-
type="button"
|
|
77
|
-
disabled={disabled}
|
|
78
|
-
onClick={onImplement}
|
|
79
|
-
className={cn(
|
|
80
|
-
"inline-flex items-center gap-1.5 rounded-md px-3 py-1.5",
|
|
81
|
-
"text-xs font-medium transition-colors",
|
|
82
|
-
"bg-primary text-primary-foreground hover:bg-primary-hover",
|
|
83
|
-
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
84
|
-
"disabled:pointer-events-none disabled:opacity-50",
|
|
85
|
-
)}
|
|
86
|
-
>
|
|
87
|
-
<ImplementIcon />
|
|
88
|
-
Implement
|
|
89
|
-
</button>
|
|
90
|
-
)}
|
|
91
|
-
</div>
|
|
80
|
+
<PlanIcon />
|
|
81
|
+
<span className="text-xs font-medium text-foreground">Plan</span>
|
|
82
|
+
<span className="text-xs tabular-nums text-muted-foreground-faint">
|
|
83
|
+
{formatArtifactSize(artifact.sizeBytes)}
|
|
84
|
+
</span>
|
|
92
85
|
|
|
93
|
-
|
|
94
|
-
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-border-muted px-3 py-1.5">
|
|
86
|
+
<div className="ml-auto flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
|
95
87
|
{org && (
|
|
96
88
|
<button
|
|
97
89
|
type="button"
|
|
98
90
|
onClick={() => setPreviewOpen(true)}
|
|
99
91
|
className={cn(
|
|
100
|
-
"inline-flex items-center gap-1 text-xs font-medium text-
|
|
92
|
+
"inline-flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground",
|
|
101
93
|
FOCUS_RING,
|
|
102
94
|
)}
|
|
103
95
|
>
|
|
104
96
|
<ExpandIcon />
|
|
105
|
-
|
|
97
|
+
Open full
|
|
106
98
|
</button>
|
|
107
99
|
)}
|
|
108
100
|
<a
|
|
@@ -116,10 +108,27 @@ export const PlanArtifactCard = memo(function PlanArtifactCard({
|
|
|
116
108
|
<DownloadIcon />
|
|
117
109
|
Download
|
|
118
110
|
</a>
|
|
111
|
+
{onImplement && (
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
disabled={disabled}
|
|
115
|
+
onClick={onImplement}
|
|
116
|
+
className={cn(
|
|
117
|
+
"inline-flex items-center gap-1.5 rounded-md px-3 py-1.5",
|
|
118
|
+
"text-xs font-medium transition-colors",
|
|
119
|
+
"bg-primary text-primary-foreground hover:bg-primary-hover",
|
|
120
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
121
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
122
|
+
)}
|
|
123
|
+
>
|
|
124
|
+
<ImplementIcon />
|
|
125
|
+
Build from plan
|
|
126
|
+
</button>
|
|
127
|
+
)}
|
|
119
128
|
</div>
|
|
120
129
|
|
|
121
|
-
{/*
|
|
122
|
-
|
|
130
|
+
{/* "Open full" reuses the shared artifact preview popup — the single place
|
|
131
|
+
that fetches/renders plan content (with Copy + Source/Rendered). */}
|
|
123
132
|
{org && previewOpen && (
|
|
124
133
|
<ArtifactPreviewModal
|
|
125
134
|
artifact={artifact}
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import { memo } from "react";
|
|
4
4
|
import { cn } from "@stigmer/theme";
|
|
5
|
+
import { useBuildFromPlanHotkey } from "./use-build-from-plan-hotkey";
|
|
5
6
|
|
|
6
7
|
/** Props for {@link PlanCompletionCard}. */
|
|
7
8
|
export interface PlanCompletionCardProps {
|
|
8
|
-
/** Called when the user clicks the "
|
|
9
|
+
/** Called when the user clicks the "Build from plan" button. */
|
|
9
10
|
readonly onImplement?: () => void;
|
|
10
11
|
/** Disables the CTA button (e.g., while an execution is active). */
|
|
11
12
|
readonly disabled?: boolean;
|
|
@@ -14,37 +15,43 @@ export interface PlanCompletionCardProps {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
18
|
+
* Fallback CTA shown in the {@link MessageThread} after a completed Plan-mode
|
|
19
|
+
* execution that did **not** publish a `plan.md` artifact (older executions, or
|
|
20
|
+
* a plan whose upload failed). When a plan artifact exists, the richer
|
|
21
|
+
* {@link PlanArtifactCard} is shown instead.
|
|
19
22
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
23
|
+
* Mirrors `PlanArtifactCard`'s hierarchy — one prominent, themeable "Build from
|
|
24
|
+
* plan" primary action with the same card-scoped `Cmd/Ctrl+Enter` accelerator —
|
|
25
|
+
* so the two cards feel like the same affordance. The consumer wires the action
|
|
26
|
+
* to switch the interaction mode to Agent and submit the implement turn.
|
|
22
27
|
*
|
|
23
|
-
* Renders nothing when `onImplement` is not provided, so the card is
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* All visual properties flow through `--stgm-*` tokens.
|
|
28
|
+
* Renders nothing when `onImplement` is not provided, so the card is fully
|
|
29
|
+
* opt-in from the consumer's perspective. All visual properties flow through
|
|
30
|
+
* `--stgm-*` tokens.
|
|
27
31
|
*/
|
|
28
32
|
export const PlanCompletionCard = memo(function PlanCompletionCard({
|
|
29
33
|
onImplement,
|
|
30
34
|
disabled,
|
|
31
35
|
className,
|
|
32
36
|
}: PlanCompletionCardProps) {
|
|
37
|
+
const handleKeyDown = useBuildFromPlanHotkey(onImplement, disabled);
|
|
38
|
+
|
|
33
39
|
if (!onImplement) return null;
|
|
34
40
|
|
|
35
41
|
return (
|
|
36
42
|
<div
|
|
37
43
|
role="status"
|
|
38
44
|
aria-label="Plan complete"
|
|
45
|
+
onKeyDown={handleKeyDown}
|
|
39
46
|
className={cn(
|
|
40
|
-
"mx-4 flex items-center gap-3 rounded-md border border-border
|
|
41
|
-
"bg-muted
|
|
47
|
+
"mx-4 flex items-center gap-3 rounded-md border border-border-muted",
|
|
48
|
+
"bg-muted-faint px-3 py-2.5",
|
|
42
49
|
className,
|
|
43
50
|
)}
|
|
44
51
|
>
|
|
45
52
|
<PlanCompleteIcon />
|
|
46
53
|
<span className="min-w-0 flex-1 text-xs font-medium text-muted-foreground">
|
|
47
|
-
Plan complete — ready to
|
|
54
|
+
Plan complete — ready to build?
|
|
48
55
|
</span>
|
|
49
56
|
<button
|
|
50
57
|
type="button"
|
|
@@ -60,7 +67,7 @@ export const PlanCompletionCard = memo(function PlanCompletionCard({
|
|
|
60
67
|
)}
|
|
61
68
|
>
|
|
62
69
|
<ImplementIcon />
|
|
63
|
-
|
|
70
|
+
Build from plan
|
|
64
71
|
</button>
|
|
65
72
|
</div>
|
|
66
73
|
);
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
});
|
|
@@ -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,33 @@ 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
|
+
});
|
|
129
156
|
});
|
|
130
157
|
|
|
131
158
|
// ---------------------------------------------------------------------------
|