@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.
Files changed (52) hide show
  1. package/execution/ArtifactContentRenderer.d.ts.map +1 -1
  2. package/execution/ArtifactContentRenderer.js +7 -3
  3. package/execution/ArtifactContentRenderer.js.map +1 -1
  4. package/execution/ArtifactPreviewModal.d.ts +3 -3
  5. package/execution/ArtifactPreviewModal.js +4 -4
  6. package/execution/ArtifactPreviewModal.js.map +1 -1
  7. package/execution/MessageEntry.d.ts.map +1 -1
  8. package/execution/MessageEntry.js +7 -3
  9. package/execution/MessageEntry.js.map +1 -1
  10. package/execution/PlanArtifactCard.d.ts +25 -14
  11. package/execution/PlanArtifactCard.d.ts.map +1 -1
  12. package/execution/PlanArtifactCard.js +25 -10
  13. package/execution/PlanArtifactCard.js.map +1 -1
  14. package/execution/PlanCompletionCard.d.ts +12 -9
  15. package/execution/PlanCompletionCard.d.ts.map +1 -1
  16. package/execution/PlanCompletionCard.js +14 -9
  17. package/execution/PlanCompletionCard.js.map +1 -1
  18. package/execution/use-build-from-plan-hotkey.d.ts +19 -0
  19. package/execution/use-build-from-plan-hotkey.d.ts.map +1 -0
  20. package/execution/use-build-from-plan-hotkey.js +30 -0
  21. package/execution/use-build-from-plan-hotkey.js.map +1 -0
  22. package/internal/markdown-components.d.ts +18 -0
  23. package/internal/markdown-components.d.ts.map +1 -1
  24. package/internal/markdown-components.js +33 -0
  25. package/internal/markdown-components.js.map +1 -1
  26. package/package.json +4 -4
  27. package/portal-container.d.ts +9 -6
  28. package/portal-container.d.ts.map +1 -1
  29. package/portal-container.js +9 -6
  30. package/portal-container.js.map +1 -1
  31. package/provider.d.ts.map +1 -1
  32. package/provider.js +41 -19
  33. package/provider.js.map +1 -1
  34. package/src/__tests__/provider-theme-scope.test.tsx +94 -0
  35. package/src/execution/ArtifactContentRenderer.tsx +11 -2
  36. package/src/execution/ArtifactPreviewModal.tsx +7 -7
  37. package/src/execution/MessageEntry.tsx +14 -3
  38. package/src/execution/PlanArtifactCard.tsx +60 -51
  39. package/src/execution/PlanCompletionCard.tsx +20 -13
  40. package/src/execution/__tests__/ArtifactContentRenderer.test.tsx +47 -0
  41. package/src/execution/__tests__/ArtifactPreviewModal.test.tsx +6 -6
  42. package/src/execution/__tests__/MessageThread.test.tsx +3 -3
  43. package/src/execution/__tests__/PlanArtifactCard.test.tsx +120 -31
  44. package/src/execution/__tests__/PlanCompletionCard.test.tsx +31 -2
  45. package/src/execution/__tests__/message-entry.test.tsx +27 -0
  46. package/src/execution/use-build-from-plan-hotkey.ts +39 -0
  47. package/src/internal/__tests__/markdown-components.test.tsx +53 -0
  48. package/src/internal/markdown-components.tsx +36 -0
  49. package/src/portal-container.ts +9 -6
  50. package/src/provider.tsx +70 -23
  51. package/src/session/inspector/__tests__/ArtifactsTab.test.tsx +7 -7
  52. 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 "Review plan" modal (the shared
17
- * {@link ArtifactPreviewModal} uses it for its detection/apply pipeline).
18
- * When omitted, the Review action is hidden — Implement and Download remain.
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 "Implement". Hidden when omitted. */
23
+ /** Called when the user clicks "Build from plan". Hidden when omitted. */
22
24
  readonly onImplement?: () => void;
23
- /** Disables the Implement CTA (e.g., while an execution is active). */
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
- * Reviewable card shown after a completed Plan-mode execution when the agent
31
- * published a `plan.md` artifact.
32
+ * Action surface for a plan that a completed Plan-mode execution published as a
33
+ * `plan.md` artifact.
32
34
  *
33
- * Presentational by design: it surfaces the plan as a first-class object with
34
- * three actions **Implement** (turn the plan into an Agent run), **Review
35
- * plan** (open the rendered plan in the shared {@link ArtifactPreviewModal},
36
- * the same popup used elsewhere for artifact previews where Copy and an
37
- * Implement CTA also live), and **Download** the `plan.md` file. The plan text
38
- * is the single source of truth (the published artifact); the modal fetches it
39
- * on demand, so the card itself holds no plan content.
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 ready to review"
70
+ aria-label="Plan actions"
71
+ onKeyDown={handleKeyDown}
58
72
  className={cn(
59
- "mx-4 overflow-hidden rounded-md border border-border-muted bg-muted-faint",
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
- {/* Header */}
64
- <div className="flex items-center gap-3 px-3 py-2.5">
65
- <PlanIcon />
66
- <div className="min-w-0 flex-1">
67
- <div className="text-xs font-medium text-foreground">
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
- {/* Secondary actions */}
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-primary transition-colors hover:text-primary-muted",
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
- Review plan
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
- {/* Review opens the shared artifact preview popup — consistent with the
122
- Artifacts tab and the single place that fetches/renders plan content. */}
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 "Implement" button. */
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
- * Inline CTA card shown in the {@link MessageThread} after a completed
18
- * Plan-mode execution.
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
- * Offers a single "Implement" action that the consumer wires to switch
21
- * the interaction mode to Agent, pre-fill the composer, and focus it.
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
- * fully opt-in from the consumer's perspective.
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/50",
41
- "bg-muted/30 px-3 py-2.5",
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 implement?
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
- Implement
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 — Implement action", () => {
31
- it("renders an Implement button only when onImplement is provided", () => {
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("Implement")).toBeNull();
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("Implement")).toBeTruthy();
57
+ expect(screen.getByText("Build from plan")).toBeTruthy();
58
58
  });
59
59
 
60
- it("calls onImplement then closes the modal when Implement is clicked", () => {
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("Implement"));
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 implementBtn = screen.getByRole("button", { name: /implement/i });
480
- expect(implementBtn).toBeTruthy();
479
+ const buildBtn = screen.getByRole("button", { name: /build from plan/i });
480
+ expect(buildBtn).toBeTruthy();
481
481
 
482
- fireEvent.click(implementBtn);
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
- agentExecution: {
23
- getArtifactContent: vi.fn().mockReturnValue(new Promise(() => {})),
24
- },
25
- } as unknown as Stigmer;
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={createStigmerMock()}>
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("renders the review header with the artifact name and size", () => {
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 ready to review" });
45
- expect(region.textContent).toContain("Plan ready to review");
46
- expect(region.textContent).toContain("plan.md");
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("calls onImplement when Implement is clicked", () => {
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("Implement"));
73
+ fireEvent.click(screen.getByText("Build from plan"));
61
74
  expect(onImplement).toHaveBeenCalledTimes(1);
62
75
  });
63
76
 
64
- it("hides Implement when onImplement is not provided", () => {
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("Implement")).toBeNull();
81
+ expect(screen.queryByText("Build from plan")).toBeNull();
69
82
  });
70
83
 
71
- it("disables Implement when disabled", () => {
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("Implement").closest("button")!;
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
- it("opens the shared preview modal when Review plan is clicked", () => {
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
- executionId="aex_1"
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 reviews.
188
+ // No dialog before the user opens it.
109
189
  expect(document.querySelector("dialog")).toBeNull();
110
190
 
111
- fireEvent.click(screen.getByText("Review plan"));
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 Review plan when org is absent (modal needs org)", () => {
119
- render(<PlanArtifactCard executionId="aex_1" artifact={planArtifact} />);
120
- expect(screen.queryByText("Review plan")).toBeNull();
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 implement button", () => {
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("Implement");
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
  // ---------------------------------------------------------------------------