@stigmer/react 3.1.3 → 3.1.4

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 (151) hide show
  1. package/composer/SessionComposer.d.ts +31 -0
  2. package/composer/SessionComposer.d.ts.map +1 -1
  3. package/composer/SessionComposer.js +12 -4
  4. package/composer/SessionComposer.js.map +1 -1
  5. package/execution/ArtifactApplyButton.d.ts +25 -0
  6. package/execution/ArtifactApplyButton.d.ts.map +1 -0
  7. package/execution/ArtifactApplyButton.js +21 -0
  8. package/execution/ArtifactApplyButton.js.map +1 -0
  9. package/execution/ArtifactContentBody.d.ts +36 -0
  10. package/execution/ArtifactContentBody.d.ts.map +1 -0
  11. package/execution/ArtifactContentBody.js +62 -0
  12. package/execution/ArtifactContentBody.js.map +1 -0
  13. package/execution/ArtifactDocument.d.ts +48 -0
  14. package/execution/ArtifactDocument.d.ts.map +1 -0
  15. package/execution/ArtifactDocument.js +89 -0
  16. package/execution/ArtifactDocument.js.map +1 -0
  17. package/execution/ArtifactPreviewModal.d.ts +4 -4
  18. package/execution/ArtifactPreviewModal.d.ts.map +1 -1
  19. package/execution/ArtifactPreviewModal.js +15 -142
  20. package/execution/ArtifactPreviewModal.js.map +1 -1
  21. package/execution/ArtifactRow.d.ts +49 -0
  22. package/execution/ArtifactRow.d.ts.map +1 -0
  23. package/execution/ArtifactRow.js +56 -0
  24. package/execution/ArtifactRow.js.map +1 -0
  25. package/execution/ArtifactsWidget.d.ts +6 -9
  26. package/execution/ArtifactsWidget.d.ts.map +1 -1
  27. package/execution/ArtifactsWidget.js +15 -19
  28. package/execution/ArtifactsWidget.js.map +1 -1
  29. package/execution/index.d.ts +8 -2
  30. package/execution/index.d.ts.map +1 -1
  31. package/execution/index.js +4 -1
  32. package/execution/index.js.map +1 -1
  33. package/execution/useArtifactContent.d.ts +6 -1
  34. package/execution/useArtifactContent.d.ts.map +1 -1
  35. package/execution/useArtifactContent.js +7 -2
  36. package/execution/useArtifactContent.js.map +1 -1
  37. package/execution/useArtifactInspection.d.ts +85 -0
  38. package/execution/useArtifactInspection.d.ts.map +1 -0
  39. package/execution/useArtifactInspection.js +143 -0
  40. package/execution/useArtifactInspection.js.map +1 -0
  41. package/execution/useCreateAgentExecution.d.ts +11 -0
  42. package/execution/useCreateAgentExecution.d.ts.map +1 -1
  43. package/execution/useCreateAgentExecution.js +1 -0
  44. package/execution/useCreateAgentExecution.js.map +1 -1
  45. package/index.d.ts +3 -3
  46. package/index.d.ts.map +1 -1
  47. package/index.js +2 -2
  48. package/index.js.map +1 -1
  49. package/internal/MermaidDiagram.d.ts +47 -0
  50. package/internal/MermaidDiagram.d.ts.map +1 -0
  51. package/internal/MermaidDiagram.js +118 -0
  52. package/internal/MermaidDiagram.js.map +1 -0
  53. package/internal/markdown-components.d.ts.map +1 -1
  54. package/internal/markdown-components.js +34 -0
  55. package/internal/markdown-components.js.map +1 -1
  56. package/internal/mermaid-loader.d.ts +26 -0
  57. package/internal/mermaid-loader.d.ts.map +1 -0
  58. package/internal/mermaid-loader.js +34 -0
  59. package/internal/mermaid-loader.js.map +1 -0
  60. package/internal/mermaid-theme-css.d.ts +41 -0
  61. package/internal/mermaid-theme-css.d.ts.map +1 -0
  62. package/internal/mermaid-theme-css.js +179 -0
  63. package/internal/mermaid-theme-css.js.map +1 -0
  64. package/internal/slug.d.ts +9 -5
  65. package/internal/slug.d.ts.map +1 -1
  66. package/internal/slug.js +10 -6
  67. package/internal/slug.js.map +1 -1
  68. package/package.json +5 -4
  69. package/session/SessionViewer.d.ts.map +1 -1
  70. package/session/SessionViewer.js +91 -18
  71. package/session/SessionViewer.js.map +1 -1
  72. package/session/artifact-document.d.ts +13 -0
  73. package/session/artifact-document.d.ts.map +1 -0
  74. package/session/artifact-document.js +22 -0
  75. package/session/artifact-document.js.map +1 -0
  76. package/session/facets/ArtifactsTab.d.ts +30 -4
  77. package/session/facets/ArtifactsTab.d.ts.map +1 -1
  78. package/session/facets/ArtifactsTab.js +32 -10
  79. package/session/facets/ArtifactsTab.js.map +1 -1
  80. package/session/index.d.ts +2 -1
  81. package/session/index.d.ts.map +1 -1
  82. package/session/index.js +4 -1
  83. package/session/index.js.map +1 -1
  84. package/session/useSessionArtifacts.d.ts +17 -0
  85. package/session/useSessionArtifacts.d.ts.map +1 -1
  86. package/session/useSessionArtifacts.js +16 -5
  87. package/session/useSessionArtifacts.js.map +1 -1
  88. package/session/useSessionConversation.d.ts +17 -1
  89. package/session/useSessionConversation.d.ts.map +1 -1
  90. package/session/useSessionConversation.js +32 -4
  91. package/session/useSessionConversation.js.map +1 -1
  92. package/session/useSessionPageFlow.d.ts.map +1 -1
  93. package/session/useSessionPageFlow.js +1 -0
  94. package/session/useSessionPageFlow.js.map +1 -1
  95. package/session/useSessionPanel.d.ts +20 -0
  96. package/session/useSessionPanel.d.ts.map +1 -1
  97. package/session/useSessionPanel.js +13 -0
  98. package/session/useSessionPanel.js.map +1 -1
  99. package/session/useSessionRailViews.d.ts +13 -1
  100. package/session/useSessionRailViews.d.ts.map +1 -1
  101. package/session/useSessionRailViews.js +5 -3
  102. package/session/useSessionRailViews.js.map +1 -1
  103. package/src/composer/SessionComposer.tsx +69 -1
  104. package/src/composer/__tests__/SessionComposer-editing.test.tsx +128 -0
  105. package/src/execution/ArtifactApplyButton.tsx +79 -0
  106. package/src/execution/ArtifactContentBody.tsx +255 -0
  107. package/src/execution/ArtifactDocument.tsx +344 -0
  108. package/src/execution/ArtifactPreviewModal.tsx +39 -405
  109. package/src/execution/ArtifactRow.tsx +165 -0
  110. package/src/execution/ArtifactsWidget.tsx +24 -33
  111. package/src/execution/__tests__/ArtifactContentBody.test.tsx +131 -0
  112. package/src/execution/__tests__/ArtifactDocument.test.tsx +100 -0
  113. package/src/execution/__tests__/ArtifactRow.test.tsx +139 -0
  114. package/src/execution/__tests__/ArtifactsWidget.test.tsx +78 -0
  115. package/src/execution/__tests__/message-entry.test.tsx +50 -2
  116. package/src/execution/__tests__/useArtifactInspection.test.tsx +151 -0
  117. package/src/execution/__tests__/useCreateAgentExecution.test.tsx +35 -0
  118. package/src/execution/index.ts +14 -2
  119. package/src/execution/useArtifactContent.ts +7 -0
  120. package/src/execution/useArtifactInspection.ts +252 -0
  121. package/src/execution/useCreateAgentExecution.ts +12 -0
  122. package/src/index.ts +11 -2
  123. package/src/internal/MermaidDiagram.tsx +164 -0
  124. package/src/internal/__tests__/a11y/mermaid-theme.a11y.test.tsx +122 -0
  125. package/src/internal/__tests__/markdown-components.test.tsx +93 -1
  126. package/src/internal/__tests__/mermaid-diagram.test.tsx +282 -0
  127. package/src/internal/markdown-components.tsx +36 -1
  128. package/src/internal/mermaid-loader.ts +44 -0
  129. package/src/internal/mermaid-theme-css.ts +178 -0
  130. package/src/internal/slug.ts +10 -6
  131. package/src/session/SessionViewer.tsx +177 -33
  132. package/src/session/__tests__/SessionViewer-artifactTabs.test.tsx +210 -0
  133. package/src/session/__tests__/SessionViewer-editResubmit.test.tsx +233 -0
  134. package/src/session/__tests__/useSessionArtifacts.test.ts +57 -0
  135. package/src/session/__tests__/useSessionConversation.test.tsx +172 -1
  136. package/src/session/__tests__/useSessionPanel.test.ts +70 -1
  137. package/src/session/artifact-document.ts +23 -0
  138. package/src/session/facets/ArtifactsTab.tsx +66 -18
  139. package/src/session/facets/__tests__/ArtifactsTab.test.tsx +111 -47
  140. package/src/session/index.ts +5 -1
  141. package/src/session/useSessionArtifacts.ts +16 -5
  142. package/src/session/useSessionConversation.ts +48 -6
  143. package/src/session/useSessionPageFlow.ts +1 -0
  144. package/src/session/useSessionPanel.ts +41 -0
  145. package/src/session/useSessionRailViews.tsx +21 -1
  146. package/styles.css +1 -1
  147. package/execution/ArtifactCard.d.ts +0 -81
  148. package/execution/ArtifactCard.d.ts.map +0 -1
  149. package/execution/ArtifactCard.js +0 -130
  150. package/execution/ArtifactCard.js.map +0 -1
  151. package/src/execution/ArtifactCard.tsx +0 -320
@@ -5,9 +5,10 @@ import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexe
5
5
  import { cn } from "@stigmer/theme";
6
6
  import {
7
7
  useSessionArtifacts,
8
+ artifactKey,
8
9
  type SessionArtifactEntry,
9
10
  } from "../session/useSessionArtifacts.js";
10
- import { ArtifactCard } from "./ArtifactCard.js";
11
+ import { ArtifactRow } from "./ArtifactRow.js";
11
12
  import { ArtifactPreviewModal } from "./ArtifactPreviewModal.js";
12
13
  import type { ApplyResourceResult } from "../library/useApplyResource.js";
13
14
 
@@ -45,19 +46,16 @@ export interface ArtifactsWidgetProps {
45
46
  * a file-explorer-like view of the conversation's output — no
46
47
  * execution/turn concepts are exposed.
47
48
  *
48
- * Composes {@link ArtifactCard} (summary + detection badges) with
49
- * {@link ArtifactPreviewModal} (full content review + Apply/Push CTA).
50
- * The card's "Preview" action opens the modal; the modal is the sole
51
- * location for Apply/Push actions (review-before-apply pattern).
49
+ * The panel-less counterpart of the session panel's Artifacts facet: it
50
+ * renders the same dense {@link ArtifactRow} list, but having no editor pane
51
+ * to open document tabs into a click opens the {@link ArtifactPreviewModal}
52
+ * (the review-before-apply popup, and the sole home of the Apply/Push CTA).
53
+ * This is the embeddable path a platform builder drops into their own sidebar.
52
54
  *
53
55
  * Returns `null` when the executions list is empty or no execution
54
56
  * has artifacts, matching the conditional-render pattern of
55
57
  * {@link ExecutionProgress} and {@link UsageWidget}.
56
58
  *
57
- * Renders without card chrome — each {@link ArtifactCard} provides its
58
- * own border and padding. The consumer controls the container styling
59
- * (or places the widget directly in a flex/grid layout).
60
- *
61
59
  * All visual properties flow through `--stgm-*` tokens.
62
60
  *
63
61
  * @example
@@ -74,7 +72,7 @@ export interface ArtifactsWidgetProps {
74
72
  * />
75
73
  * ```
76
74
  *
77
- * @see {@link ArtifactCard} — compact summary card per artifact
75
+ * @see {@link ArtifactRow} — the dense row rendered per artifact
78
76
  * @see {@link ArtifactPreviewModal} — full preview with Apply/Push CTA
79
77
  * @see {@link useSessionArtifacts} — headless session-level artifact aggregation hook
80
78
  * @see {@link useExecutionArtifacts} — headless single-execution artifact extraction hook
@@ -88,27 +86,22 @@ export function ArtifactsWidget({
88
86
  const { artifacts, hasArtifacts, artifactCount } =
89
87
  useSessionArtifacts(executions);
90
88
 
91
- // Store the dedup key (sandboxPath or name) instead of a snapshot of
92
- // the full entry. The actual entry is derived from the live artifacts
93
- // list on every render, so the preview modal always reflects the
94
- // latest artifact version even when a newer execution publishes an
95
- // updated artifact for the same path while the modal is open.
89
+ // Store the artifact identity (not a snapshot of the full entry). The entry is
90
+ // re-derived from the live artifacts list on every render, so the modal always
91
+ // reflects the latest artifact version even when a newer execution publishes
92
+ // an updated artifact for the same path while the modal is open.
96
93
  const [previewKey, setPreviewKey] = useState<string | null>(null);
97
94
 
98
95
  const previewEntry = useMemo<SessionArtifactEntry | null>(
99
96
  () =>
100
97
  previewKey !== null
101
- ? artifacts.find(
102
- (e) =>
103
- (e.artifact.sandboxPath || e.artifact.name) === previewKey,
104
- ) ?? null
98
+ ? artifacts.find((e) => artifactKey(e.artifact) === previewKey) ?? null
105
99
  : null,
106
100
  [previewKey, artifacts],
107
101
  );
108
102
 
109
- const handlePreview = useCallback(
110
- (entry: SessionArtifactEntry) =>
111
- setPreviewKey(entry.artifact.sandboxPath || entry.artifact.name),
103
+ const handleOpen = useCallback(
104
+ (entry: SessionArtifactEntry) => setPreviewKey(artifactKey(entry.artifact)),
112
105
  [],
113
106
  );
114
107
 
@@ -123,19 +116,17 @@ export function ArtifactsWidget({
123
116
  </span>
124
117
  </div>
125
118
 
126
- <div role="list" className="space-y-2">
119
+ <ul role="list" className="flex flex-col">
127
120
  {artifacts.map((entry) => (
128
- <div key={entry.artifact.storageKey} role="listitem">
129
- <ArtifactCard
130
- artifact={entry.artifact}
131
- executionId={entry.executionId}
132
- org={org}
133
- hasNameCollision={entry.hasNameCollision}
134
- onPreview={() => handlePreview(entry)}
135
- />
136
- </div>
121
+ <ArtifactRow
122
+ key={artifactKey(entry.artifact)}
123
+ artifact={entry.artifact}
124
+ executionId={entry.executionId}
125
+ hasNameCollision={entry.hasNameCollision}
126
+ onOpen={() => handleOpen(entry)}
127
+ />
137
128
  ))}
138
- </div>
129
+ </ul>
139
130
 
140
131
  {previewEntry && (
141
132
  <ArtifactPreviewModal
@@ -0,0 +1,131 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { render, screen, cleanup } from "@testing-library/react";
3
+ import { create } from "@bufbuild/protobuf";
4
+ import { ExecutionArtifactSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/artifact_pb";
5
+ import { ExecutionArtifactKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
6
+ import { ArtifactContentBody } from "../ArtifactContentBody";
7
+ import type { SkillPackageDetection } from "../../library/detect-skill-package";
8
+
9
+ const NOT_SKILL: SkillPackageDetection = { detected: false };
10
+
11
+ function fileArtifact(name: string) {
12
+ return create(ExecutionArtifactSchema, {
13
+ name,
14
+ kind: ExecutionArtifactKind.FILE,
15
+ sizeBytes: 64n,
16
+ });
17
+ }
18
+
19
+ function dirArtifact(name: string, entries: string[]) {
20
+ return create(ExecutionArtifactSchema, {
21
+ name,
22
+ kind: ExecutionArtifactKind.DIRECTORY,
23
+ sizeBytes: 512n,
24
+ entries,
25
+ });
26
+ }
27
+
28
+ afterEach(cleanup);
29
+
30
+ describe("ArtifactContentBody — file states", () => {
31
+ it("renders a loading skeleton", () => {
32
+ render(
33
+ <ArtifactContentBody
34
+ artifact={fileArtifact("notes.txt")}
35
+ content={null}
36
+ contentType={null}
37
+ isLoading
38
+ error={null}
39
+ isTruncated={false}
40
+ skillDetection={NOT_SKILL}
41
+ />,
42
+ );
43
+ expect(screen.getByLabelText("Loading content")).toBeTruthy();
44
+ });
45
+
46
+ it("renders an error message", () => {
47
+ render(
48
+ <ArtifactContentBody
49
+ artifact={fileArtifact("notes.txt")}
50
+ content={null}
51
+ contentType={null}
52
+ isLoading={false}
53
+ error={new Error("boom")}
54
+ isTruncated={false}
55
+ skillDetection={NOT_SKILL}
56
+ />,
57
+ );
58
+ expect(screen.getByText("boom")).toBeTruthy();
59
+ });
60
+
61
+ it("renders the binary / unavailable state when content is null and settled", () => {
62
+ render(
63
+ <ArtifactContentBody
64
+ artifact={fileArtifact("image.bin")}
65
+ content={null}
66
+ contentType={null}
67
+ isLoading={false}
68
+ error={null}
69
+ isTruncated={false}
70
+ skillDetection={NOT_SKILL}
71
+ />,
72
+ );
73
+ expect(screen.getByText(/Content not available for preview/)).toBeTruthy();
74
+ });
75
+
76
+ it("renders text content", () => {
77
+ render(
78
+ <ArtifactContentBody
79
+ artifact={fileArtifact("notes.txt")}
80
+ content={"hello from the artifact"}
81
+ contentType={"text/plain"}
82
+ isLoading={false}
83
+ error={null}
84
+ isTruncated={false}
85
+ skillDetection={NOT_SKILL}
86
+ />,
87
+ );
88
+ expect(screen.getByText(/hello from the artifact/)).toBeTruthy();
89
+ });
90
+ });
91
+
92
+ describe("ArtifactContentBody — directory state", () => {
93
+ it("lists directory entries", () => {
94
+ render(
95
+ <ArtifactContentBody
96
+ artifact={dirArtifact("pack", ["SKILL.md", "run.sh"])}
97
+ content={null}
98
+ contentType={null}
99
+ isLoading={false}
100
+ error={null}
101
+ isTruncated={false}
102
+ skillDetection={NOT_SKILL}
103
+ />,
104
+ );
105
+ expect(screen.getByText("Files (2)")).toBeTruthy();
106
+ expect(screen.getByText("SKILL.md")).toBeTruthy();
107
+ expect(screen.getByText("run.sh")).toBeTruthy();
108
+ });
109
+
110
+ it("shows the skill banner when a skill package is detected", () => {
111
+ const detection = {
112
+ detected: true,
113
+ skillName: "My Skill",
114
+ skillDescription: "does things",
115
+ fileCount: 2,
116
+ } as unknown as SkillPackageDetection;
117
+ render(
118
+ <ArtifactContentBody
119
+ artifact={dirArtifact("pack", ["SKILL.md"])}
120
+ content={null}
121
+ contentType={null}
122
+ isLoading={false}
123
+ error={null}
124
+ isTruncated={false}
125
+ skillDetection={detection}
126
+ />,
127
+ );
128
+ expect(screen.getByText("My Skill")).toBeTruthy();
129
+ expect(screen.getByText("does things")).toBeTruthy();
130
+ });
131
+ });
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { render, screen, cleanup, waitFor } from "@testing-library/react";
3
+ import { create } from "@bufbuild/protobuf";
4
+ import type { Stigmer } from "@stigmer/sdk";
5
+ import { ExecutionArtifactSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/artifact_pb";
6
+ import { ExecutionArtifactKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
7
+ import { StigmerContext } from "../../context";
8
+ import { ArtifactDocument } from "../ArtifactDocument";
9
+
10
+ function fileArtifact(name: string) {
11
+ return create(ExecutionArtifactSchema, {
12
+ name,
13
+ kind: ExecutionArtifactKind.FILE,
14
+ sizeBytes: 64n,
15
+ storageKey: `artifacts/aex_1/${name}`,
16
+ contentHash: "hash-1",
17
+ });
18
+ }
19
+
20
+ function dirArtifact(name: string) {
21
+ return create(ExecutionArtifactSchema, {
22
+ name,
23
+ kind: ExecutionArtifactKind.DIRECTORY,
24
+ sizeBytes: 512n,
25
+ storageKey: `artifacts/aex_1/${name}`,
26
+ entries: ["a.txt", "b.txt"],
27
+ });
28
+ }
29
+
30
+ function stigmerReturning(text: string): Stigmer {
31
+ return {
32
+ agentExecution: {
33
+ getArtifactContent: vi.fn().mockResolvedValue({
34
+ content: new TextEncoder().encode(text),
35
+ contentType: "text/plain",
36
+ truncated: false,
37
+ }),
38
+ },
39
+ } as unknown as Stigmer;
40
+ }
41
+
42
+ function renderDoc(stigmer: Stigmer, artifact = fileArtifact("notes.txt")) {
43
+ return render(
44
+ <StigmerContext.Provider value={stigmer}>
45
+ <ArtifactDocument
46
+ artifact={artifact}
47
+ executionId="aex_1"
48
+ org="acme"
49
+ isTerminal
50
+ />
51
+ </StigmerContext.Provider>,
52
+ );
53
+ }
54
+
55
+ const AGENT_YAML = [
56
+ "apiVersion: agentic.stigmer.ai/v1",
57
+ "kind: Agent",
58
+ "metadata:",
59
+ " name: my-agent",
60
+ "spec: {}",
61
+ "",
62
+ ].join("\n");
63
+
64
+ afterEach(cleanup);
65
+
66
+ describe("ArtifactDocument", () => {
67
+ it("renders the toolbar (name + Download) and the fetched content", async () => {
68
+ renderDoc(stigmerReturning("hello document"));
69
+
70
+ expect(screen.getByRole("article", { name: "Artifact notes.txt" })).toBeTruthy();
71
+ expect(screen.getByText("notes.txt")).toBeTruthy();
72
+ expect(screen.getByText("Download")).toBeTruthy();
73
+ await waitFor(() => expect(screen.getByText(/hello document/)).toBeTruthy());
74
+ expect(screen.getByText("Copy")).toBeTruthy();
75
+ });
76
+
77
+ it("surfaces resource detection and an Apply CTA for a detected Agent", async () => {
78
+ renderDoc(stigmerReturning(AGENT_YAML), fileArtifact("agent.yaml"));
79
+
80
+ await waitFor(() => expect(screen.getByText("Agent detected")).toBeTruthy());
81
+ expect(screen.getByText("Apply to acme")).toBeTruthy();
82
+ });
83
+
84
+ it("renders a directory artifact with a Download ZIP action and no Copy", () => {
85
+ render(
86
+ <StigmerContext.Provider value={stigmerReturning("")}>
87
+ <ArtifactDocument
88
+ artifact={dirArtifact("pack")}
89
+ executionId="aex_1"
90
+ org="acme"
91
+ isTerminal
92
+ />
93
+ </StigmerContext.Provider>,
94
+ );
95
+
96
+ expect(screen.getByText("Download ZIP")).toBeTruthy();
97
+ expect(screen.queryByText("Copy")).toBeNull();
98
+ expect(screen.getByText("Files (2)")).toBeTruthy();
99
+ });
100
+ });
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { render, screen, fireEvent, cleanup } from "@testing-library/react";
3
+ import { create } from "@bufbuild/protobuf";
4
+ import type { Stigmer } from "@stigmer/sdk";
5
+ import { ExecutionArtifactSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/artifact_pb";
6
+ import { ExecutionArtifactKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
7
+ import { StigmerContext } from "../../context";
8
+ import { ArtifactRow } from "../ArtifactRow";
9
+
10
+ function fileArtifact(name: string, sandboxPath = `.stigmer/${name}`) {
11
+ return create(ExecutionArtifactSchema, {
12
+ name,
13
+ kind: ExecutionArtifactKind.FILE,
14
+ sizeBytes: 2048n,
15
+ sandboxPath,
16
+ storageKey: `artifacts/aex_1/${name}`,
17
+ });
18
+ }
19
+
20
+ function dirArtifact(name: string) {
21
+ return create(ExecutionArtifactSchema, {
22
+ name,
23
+ kind: ExecutionArtifactKind.DIRECTORY,
24
+ sizeBytes: 4096n,
25
+ sandboxPath: `.stigmer/${name}`,
26
+ storageKey: `artifacts/aex_1/${name}`,
27
+ });
28
+ }
29
+
30
+ const getArtifactDownloadUrl = vi.fn().mockResolvedValue({ downloadUrl: "" });
31
+
32
+ function createStigmerMock(): Stigmer {
33
+ return {
34
+ agentExecution: { getArtifactDownloadUrl },
35
+ } as unknown as Stigmer;
36
+ }
37
+
38
+ function renderRow(props: Partial<Parameters<typeof ArtifactRow>[0]> = {}) {
39
+ const merged = {
40
+ artifact: fileArtifact("notes.md"),
41
+ executionId: "aex_1",
42
+ onOpen: vi.fn(),
43
+ ...props,
44
+ } as Parameters<typeof ArtifactRow>[0];
45
+ return render(
46
+ <StigmerContext.Provider value={createStigmerMock()}>
47
+ <ul>
48
+ <ArtifactRow {...merged} />
49
+ </ul>
50
+ </StigmerContext.Provider>,
51
+ );
52
+ }
53
+
54
+ afterEach(() => {
55
+ cleanup();
56
+ getArtifactDownloadUrl.mockClear();
57
+ });
58
+
59
+ describe("ArtifactRow — presentation", () => {
60
+ it("renders a file-type icon, the name, and the formatted size", () => {
61
+ renderRow({ artifact: fileArtifact("notes.md") });
62
+ const open = screen.getByText("notes.md").closest("button")!;
63
+ expect(open.querySelector("svg")).toBeTruthy();
64
+ expect(screen.getByText("2.0 KB")).toBeTruthy();
65
+ });
66
+
67
+ it("appends a trailing slash for a directory artifact", () => {
68
+ renderRow({ artifact: dirArtifact("return-policy") });
69
+ // The name span's own text is "return-policy" + "/".
70
+ expect(screen.getByText("return-policy/")).toBeTruthy();
71
+ });
72
+
73
+ it("shows the parent-directory subtitle only on a name collision", () => {
74
+ const artifact = fileArtifact("agent.yaml", "/workspace/configs/agent.yaml");
75
+ const { rerender } = renderRow({ artifact, hasNameCollision: true });
76
+ expect(screen.getByText("configs/")).toBeTruthy();
77
+
78
+ rerender(
79
+ <StigmerContext.Provider value={createStigmerMock()}>
80
+ <ul>
81
+ <ArtifactRow artifact={artifact} executionId="aex_1" onOpen={vi.fn()} />
82
+ </ul>
83
+ </StigmerContext.Provider>,
84
+ );
85
+ expect(screen.queryByText("configs/")).toBeNull();
86
+ });
87
+ });
88
+
89
+ describe("ArtifactRow — nested-interactive avoidance (DD-22)", () => {
90
+ it("renders the Download control as a SIBLING of the open button, never nested", () => {
91
+ renderRow({ artifact: fileArtifact("notes.md") });
92
+ const open = screen.getByText("notes.md").closest("button")!;
93
+ const download = screen.getByLabelText("Download notes.md");
94
+ expect(download.tagName).toBe("BUTTON");
95
+ // A <button> inside a <button> is a WCAG 4.1.2 nested-interactive violation.
96
+ expect(open.contains(download)).toBe(false);
97
+ });
98
+ });
99
+
100
+ describe("ArtifactRow — open / activate interaction (file-tree parity)", () => {
101
+ it("fires onOpen on a single click of the open target", () => {
102
+ const onOpen = vi.fn();
103
+ renderRow({ onOpen });
104
+ fireEvent.click(screen.getByText("notes.md"));
105
+ expect(onOpen).toHaveBeenCalledTimes(1);
106
+ });
107
+
108
+ it("fires onActivate on a double-click when provided", () => {
109
+ const onActivate = vi.fn();
110
+ renderRow({ onActivate });
111
+ fireEvent.doubleClick(screen.getByText("notes.md"));
112
+ expect(onActivate).toHaveBeenCalledTimes(1);
113
+ });
114
+
115
+ it("binds no double-click handler when onActivate is omitted (harmless)", () => {
116
+ const onOpen = vi.fn();
117
+ renderRow({ onOpen });
118
+ // With no onActivate, a dblclick fires nothing (onOpen only responds to click).
119
+ fireEvent.doubleClick(screen.getByText("notes.md"));
120
+ expect(onOpen).not.toHaveBeenCalled();
121
+ });
122
+ });
123
+
124
+ describe("ArtifactRow — download", () => {
125
+ it("mints a download URL for the artifact's storage key on Download click", () => {
126
+ renderRow({ artifact: fileArtifact("notes.md") });
127
+ fireEvent.click(screen.getByLabelText("Download notes.md"));
128
+ expect(getArtifactDownloadUrl).toHaveBeenCalledTimes(1);
129
+ expect(getArtifactDownloadUrl.mock.calls[0][0]).toMatchObject({
130
+ executionId: "aex_1",
131
+ storageKey: "artifacts/aex_1/notes.md",
132
+ });
133
+ });
134
+
135
+ it("labels the Download control 'Download ZIP' for a directory", () => {
136
+ renderRow({ artifact: dirArtifact("return-policy") });
137
+ expect(screen.getByTitle("Download ZIP")).toBeTruthy();
138
+ });
139
+ });
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { render, screen, fireEvent, cleanup } from "@testing-library/react";
3
+ import { create } from "@bufbuild/protobuf";
4
+ import type { Stigmer } from "@stigmer/sdk";
5
+ import { AgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
6
+ import { ExecutionArtifactSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/artifact_pb";
7
+ import {
8
+ ExecutionArtifactKind,
9
+ ExecutionPhase,
10
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
11
+ import { StigmerContext } from "../../context";
12
+ import { ArtifactsWidget } from "../ArtifactsWidget";
13
+
14
+ function artifact(name: string) {
15
+ return create(ExecutionArtifactSchema, {
16
+ name,
17
+ kind: ExecutionArtifactKind.FILE,
18
+ sizeBytes: 1024n,
19
+ sandboxPath: `.stigmer/${name}`,
20
+ storageKey: `artifacts/aex_1/${name}`,
21
+ });
22
+ }
23
+
24
+ const execution = create(AgentExecutionSchema, {
25
+ metadata: { id: "aex_1" },
26
+ status: {
27
+ phase: ExecutionPhase.EXECUTION_COMPLETED,
28
+ artifacts: [artifact("agent.yaml"), artifact("notes.md")],
29
+ },
30
+ });
31
+
32
+ /** Modal content fetch — keep pending so nothing rejects during the test. */
33
+ function createStigmerMock(): Stigmer {
34
+ return {
35
+ agentExecution: {
36
+ getArtifactContent: vi.fn().mockReturnValue(new Promise(() => {})),
37
+ getArtifactDownloadUrl: vi.fn().mockResolvedValue({ downloadUrl: "" }),
38
+ },
39
+ } as unknown as Stigmer;
40
+ }
41
+
42
+ function renderWidget(executions = [execution]) {
43
+ return render(
44
+ <StigmerContext.Provider value={createStigmerMock()}>
45
+ <ArtifactsWidget executions={executions} org="acme" />
46
+ </StigmerContext.Provider>,
47
+ );
48
+ }
49
+
50
+ afterEach(cleanup);
51
+
52
+ describe("ArtifactsWidget — dense list (panel-less)", () => {
53
+ it("renders nothing when there are no artifacts", () => {
54
+ const { container } = renderWidget([]);
55
+ expect(container.firstChild).toBeNull();
56
+ });
57
+
58
+ it("renders a heading, count, and one dense row per artifact (no ArtifactCard chrome)", () => {
59
+ renderWidget();
60
+ expect(screen.getByRole("heading", { name: "Artifacts" })).toBeTruthy();
61
+ // Count badge reflects the two deduped artifacts.
62
+ expect(screen.getByText("2")).toBeTruthy();
63
+ expect(screen.getAllByRole("listitem")).toHaveLength(2);
64
+ expect(screen.getByText("agent.yaml")).toBeTruthy();
65
+ expect(screen.getByText("notes.md")).toBeTruthy();
66
+ // The retired ArtifactCard rendered a "Preview" button and role="article".
67
+ expect(screen.queryByText("Preview")).toBeNull();
68
+ expect(screen.queryByRole("article")).toBeNull();
69
+ expect(screen.getByLabelText("Download agent.yaml")).toBeTruthy();
70
+ });
71
+
72
+ it("opens the preview modal (the panel-less fallback) on a row click", () => {
73
+ renderWidget();
74
+ expect(document.querySelector("dialog")).toBeNull();
75
+ fireEvent.click(screen.getByText("notes.md"));
76
+ expect(document.querySelector("dialog")).toBeTruthy();
77
+ });
78
+ });
@@ -1,9 +1,22 @@
1
- import { describe, it, expect, afterEach } from "vitest";
2
- import { render, cleanup } from "@testing-library/react";
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
+ import { render, cleanup, waitFor } from "@testing-library/react";
3
3
  import { create } from "@bufbuild/protobuf";
4
4
  import { AgentMessageSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
5
5
  import { MessageType } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
6
6
  import { MessageEntry } from "../MessageEntry";
7
+ import type { MermaidModule } from "../../internal/mermaid-loader";
8
+
9
+ // Stub only the library loader: the real MermaidDiagram + markdown overrides
10
+ // run end-to-end through Streamdown, so these tests cover the full chat path
11
+ // from fenced source to rendered diagram.
12
+ vi.mock("../../internal/mermaid-loader", () => ({
13
+ loadMermaid: vi.fn().mockResolvedValue({
14
+ initialize: vi.fn(),
15
+ render: vi
16
+ .fn()
17
+ .mockResolvedValue({ svg: "<svg data-testid='mermaid-svg'></svg>" }),
18
+ } as unknown as MermaidModule),
19
+ }));
7
20
 
8
21
  afterEach(cleanup);
9
22
 
@@ -169,6 +182,41 @@ describe("MessageEntry — AI messages (Streamdown)", () => {
169
182
  article!.querySelectorAll('span[class*="hljs-"]').length,
170
183
  ).toBeGreaterThan(0);
171
184
  });
185
+
186
+ it("renders a completed ```mermaid fence as a diagram, not a code block", async () => {
187
+ const msg = makeMessage(
188
+ MessageType.MESSAGE_AI,
189
+ "Here is the flow:\n\n```mermaid\nflowchart LR\n A --> B\n```",
190
+ );
191
+ const { container } = render(<MessageEntry message={msg} />);
192
+
193
+ const article = queryArticle(container, "AI response");
194
+ await waitFor(() =>
195
+ expect(
196
+ article!.querySelector('[role="img"][aria-label="Mermaid diagram"]'),
197
+ ).not.toBeNull(),
198
+ );
199
+ expect(article!.querySelector("[data-testid='mermaid-svg']")).not.toBeNull();
200
+ // The fenced source is no longer shown as a code block.
201
+ expect(article!.querySelector("pre")).toBeNull();
202
+ });
203
+
204
+ it("keeps an unclosed ```mermaid fence as a code block while streaming", () => {
205
+ const msg = makeMessage(
206
+ MessageType.MESSAGE_AI,
207
+ "```mermaid\nflowchart LR\n A --> B",
208
+ { isStreaming: true },
209
+ );
210
+ const { container } = render(<MessageEntry message={msg} />);
211
+
212
+ const article = queryArticle(container, "AI response");
213
+ // Mid-stream the source stays a plain code block — no diagram, and no
214
+ // half-parsed error flashing.
215
+ expect(
216
+ article!.querySelector('[role="img"][aria-label="Mermaid diagram"]'),
217
+ ).toBeNull();
218
+ expect(article!.textContent).toContain("flowchart LR");
219
+ });
172
220
  });
173
221
 
174
222
  // ---------------------------------------------------------------------------