@stigmer/react 3.1.3 → 3.1.5

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 (248) 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/FileReviewContext.d.ts +15 -1
  30. package/execution/FileReviewContext.d.ts.map +1 -1
  31. package/execution/FileReviewContext.js +20 -1
  32. package/execution/FileReviewContext.js.map +1 -1
  33. package/execution/MessageThread.d.ts +65 -2
  34. package/execution/MessageThread.d.ts.map +1 -1
  35. package/execution/MessageThread.js +42 -23
  36. package/execution/MessageThread.js.map +1 -1
  37. package/execution/TodoCard.d.ts +8 -0
  38. package/execution/TodoCard.d.ts.map +1 -1
  39. package/execution/TodoCard.js +6 -4
  40. package/execution/TodoCard.js.map +1 -1
  41. package/execution/TodoList.d.ts +20 -1
  42. package/execution/TodoList.d.ts.map +1 -1
  43. package/execution/TodoList.js +9 -6
  44. package/execution/TodoList.js.map +1 -1
  45. package/execution/ToolCallDetail.d.ts +3 -2
  46. package/execution/ToolCallDetail.d.ts.map +1 -1
  47. package/execution/ToolCallDetail.js +49 -16
  48. package/execution/ToolCallDetail.js.map +1 -1
  49. package/execution/file-review-status.d.ts +15 -0
  50. package/execution/file-review-status.d.ts.map +1 -1
  51. package/execution/file-review-status.js +22 -0
  52. package/execution/file-review-status.js.map +1 -1
  53. package/execution/index.d.ts +13 -7
  54. package/execution/index.d.ts.map +1 -1
  55. package/execution/index.js +7 -4
  56. package/execution/index.js.map +1 -1
  57. package/execution/useArtifactContent.d.ts +6 -1
  58. package/execution/useArtifactContent.d.ts.map +1 -1
  59. package/execution/useArtifactContent.js +7 -2
  60. package/execution/useArtifactContent.js.map +1 -1
  61. package/execution/useArtifactInspection.d.ts +85 -0
  62. package/execution/useArtifactInspection.d.ts.map +1 -0
  63. package/execution/useArtifactInspection.js +143 -0
  64. package/execution/useArtifactInspection.js.map +1 -0
  65. package/execution/useCreateAgentExecution.d.ts +11 -0
  66. package/execution/useCreateAgentExecution.d.ts.map +1 -1
  67. package/execution/useCreateAgentExecution.js +1 -0
  68. package/execution/useCreateAgentExecution.js.map +1 -1
  69. package/github/useGitHubFileReader.d.ts +4 -2
  70. package/github/useGitHubFileReader.d.ts.map +1 -1
  71. package/github/useGitHubFileReader.js +16 -3
  72. package/github/useGitHubFileReader.js.map +1 -1
  73. package/github/useGitHubTreeLister.d.ts +2 -1
  74. package/github/useGitHubTreeLister.d.ts.map +1 -1
  75. package/github/useGitHubTreeLister.js +7 -3
  76. package/github/useGitHubTreeLister.js.map +1 -1
  77. package/index.d.ts +4 -4
  78. package/index.d.ts.map +1 -1
  79. package/index.js +3 -3
  80. package/index.js.map +1 -1
  81. package/internal/MermaidDiagram.d.ts +47 -0
  82. package/internal/MermaidDiagram.d.ts.map +1 -0
  83. package/internal/MermaidDiagram.js +118 -0
  84. package/internal/MermaidDiagram.js.map +1 -0
  85. package/internal/VirtualizedThread.d.ts +3 -2
  86. package/internal/VirtualizedThread.d.ts.map +1 -1
  87. package/internal/VirtualizedThread.js +3 -2
  88. package/internal/VirtualizedThread.js.map +1 -1
  89. package/internal/markdown-components.d.ts.map +1 -1
  90. package/internal/markdown-components.js +34 -0
  91. package/internal/markdown-components.js.map +1 -1
  92. package/internal/mermaid-loader.d.ts +26 -0
  93. package/internal/mermaid-loader.d.ts.map +1 -0
  94. package/internal/mermaid-loader.js +34 -0
  95. package/internal/mermaid-loader.js.map +1 -0
  96. package/internal/mermaid-theme-css.d.ts +41 -0
  97. package/internal/mermaid-theme-css.d.ts.map +1 -0
  98. package/internal/mermaid-theme-css.js +179 -0
  99. package/internal/mermaid-theme-css.js.map +1 -0
  100. package/internal/slug.d.ts +9 -5
  101. package/internal/slug.d.ts.map +1 -1
  102. package/internal/slug.js +10 -6
  103. package/internal/slug.js.map +1 -1
  104. package/package.json +5 -4
  105. package/session/SessionViewer.d.ts +11 -1
  106. package/session/SessionViewer.d.ts.map +1 -1
  107. package/session/SessionViewer.js +101 -21
  108. package/session/SessionViewer.js.map +1 -1
  109. package/session/artifact-document.d.ts +13 -0
  110. package/session/artifact-document.d.ts.map +1 -0
  111. package/session/artifact-document.js +22 -0
  112. package/session/artifact-document.js.map +1 -0
  113. package/session/facets/ArtifactsTab.d.ts +30 -4
  114. package/session/facets/ArtifactsTab.d.ts.map +1 -1
  115. package/session/facets/ArtifactsTab.js +32 -10
  116. package/session/facets/ArtifactsTab.js.map +1 -1
  117. package/session/index.d.ts +3 -1
  118. package/session/index.d.ts.map +1 -1
  119. package/session/index.js +5 -1
  120. package/session/index.js.map +1 -1
  121. package/session/useSessionArtifacts.d.ts +17 -0
  122. package/session/useSessionArtifacts.d.ts.map +1 -1
  123. package/session/useSessionArtifacts.js +16 -5
  124. package/session/useSessionArtifacts.js.map +1 -1
  125. package/session/useSessionConversation.d.ts +17 -1
  126. package/session/useSessionConversation.d.ts.map +1 -1
  127. package/session/useSessionConversation.js +32 -4
  128. package/session/useSessionConversation.js.map +1 -1
  129. package/session/useSessionPageFlow.d.ts.map +1 -1
  130. package/session/useSessionPageFlow.js +1 -0
  131. package/session/useSessionPageFlow.js.map +1 -1
  132. package/session/useSessionPanel.d.ts +20 -0
  133. package/session/useSessionPanel.d.ts.map +1 -1
  134. package/session/useSessionPanel.js +13 -0
  135. package/session/useSessionPanel.js.map +1 -1
  136. package/session/useSessionRailViews.d.ts +13 -1
  137. package/session/useSessionRailViews.d.ts.map +1 -1
  138. package/session/useSessionRailViews.js +5 -3
  139. package/session/useSessionRailViews.js.map +1 -1
  140. package/session/useWorkspaceReadRefs.d.ts +45 -0
  141. package/session/useWorkspaceReadRefs.d.ts.map +1 -0
  142. package/session/useWorkspaceReadRefs.js +104 -0
  143. package/session/useWorkspaceReadRefs.js.map +1 -0
  144. package/src/composer/SessionComposer.tsx +69 -1
  145. package/src/composer/__tests__/SessionComposer-editing.test.tsx +128 -0
  146. package/src/execution/ArtifactApplyButton.tsx +79 -0
  147. package/src/execution/ArtifactContentBody.tsx +255 -0
  148. package/src/execution/ArtifactDocument.tsx +344 -0
  149. package/src/execution/ArtifactPreviewModal.tsx +39 -405
  150. package/src/execution/ArtifactRow.tsx +165 -0
  151. package/src/execution/ArtifactsWidget.tsx +24 -33
  152. package/src/execution/FileReviewContext.ts +27 -1
  153. package/src/execution/MessageThread.tsx +110 -22
  154. package/src/execution/TodoCard.tsx +20 -4
  155. package/src/execution/TodoList.tsx +26 -4
  156. package/src/execution/ToolCallDetail.tsx +69 -16
  157. package/src/execution/__tests__/ArtifactContentBody.test.tsx +131 -0
  158. package/src/execution/__tests__/ArtifactDocument.test.tsx +100 -0
  159. package/src/execution/__tests__/ArtifactRow.test.tsx +139 -0
  160. package/src/execution/__tests__/ArtifactsWidget.test.tsx +78 -0
  161. package/src/execution/__tests__/ToolCallDetail.test.tsx +188 -2
  162. package/src/execution/__tests__/file-review-status.test.ts +47 -0
  163. package/src/execution/__tests__/message-entry.test.tsx +50 -2
  164. package/src/execution/__tests__/message-thread-slots.test.tsx +463 -0
  165. package/src/execution/__tests__/useArtifactInspection.test.tsx +151 -0
  166. package/src/execution/__tests__/useCreateAgentExecution.test.tsx +35 -0
  167. package/src/execution/file-review-status.ts +24 -0
  168. package/src/execution/index.ts +27 -5
  169. package/src/execution/useArtifactContent.ts +7 -0
  170. package/src/execution/useArtifactInspection.ts +252 -0
  171. package/src/execution/useCreateAgentExecution.ts +12 -0
  172. package/src/github/__tests__/useGitHubFileReader.test.ts +35 -2
  173. package/src/github/__tests__/useGitHubTreeLister.test.ts +14 -0
  174. package/src/github/useGitHubFileReader.ts +19 -6
  175. package/src/github/useGitHubTreeLister.ts +7 -3
  176. package/src/index.ts +15 -2
  177. package/src/internal/MermaidDiagram.tsx +164 -0
  178. package/src/internal/VirtualizedThread.tsx +5 -1
  179. package/src/internal/__tests__/a11y/mermaid-theme.a11y.test.tsx +122 -0
  180. package/src/internal/__tests__/markdown-components.test.tsx +93 -1
  181. package/src/internal/__tests__/mermaid-diagram.test.tsx +282 -0
  182. package/src/internal/markdown-components.tsx +36 -1
  183. package/src/internal/mermaid-loader.ts +44 -0
  184. package/src/internal/mermaid-theme-css.ts +178 -0
  185. package/src/internal/slug.ts +10 -6
  186. package/src/session/SessionViewer.tsx +204 -35
  187. package/src/session/__tests__/SessionViewer-artifactTabs.test.tsx +210 -0
  188. package/src/session/__tests__/SessionViewer-editResubmit.test.tsx +233 -0
  189. package/src/session/__tests__/useSessionArtifacts.test.ts +57 -0
  190. package/src/session/__tests__/useSessionConversation.test.tsx +172 -1
  191. package/src/session/__tests__/useSessionPanel.test.ts +70 -1
  192. package/src/session/__tests__/useWorkspaceReadRefs.test.ts +245 -0
  193. package/src/session/artifact-document.ts +23 -0
  194. package/src/session/facets/ArtifactsTab.tsx +66 -18
  195. package/src/session/facets/__tests__/ArtifactsTab.test.tsx +111 -47
  196. package/src/session/index.ts +7 -1
  197. package/src/session/useSessionArtifacts.ts +16 -5
  198. package/src/session/useSessionConversation.ts +48 -6
  199. package/src/session/useSessionPageFlow.ts +1 -0
  200. package/src/session/useSessionPanel.ts +41 -0
  201. package/src/session/useSessionRailViews.tsx +21 -1
  202. package/src/session/useWorkspaceReadRefs.ts +118 -0
  203. package/src/workspace/FileViewer.tsx +144 -38
  204. package/src/workspace/WorkspaceFileReader.ts +21 -0
  205. package/src/workspace/__tests__/FileViewer.test.tsx +138 -3
  206. package/src/workspace/__tests__/useWorkspaceFileContent.test.tsx +20 -0
  207. package/src/workspace/__tests__/workspaceListingCache.test.ts +114 -0
  208. package/src/workspace/index.ts +4 -1
  209. package/src/workspace/useWorkspaceEntries.ts +11 -0
  210. package/src/workspace/useWorkspaceFileContent.ts +4 -1
  211. package/src/workspace/useWorkspaceFileSearch.ts +8 -3
  212. package/src/workspace/useWorkspaceFiles.ts +3 -2
  213. package/src/workspace/workspaceListingCache.ts +21 -7
  214. package/styles.css +1 -1
  215. package/workspace/FileViewer.d.ts +1 -1
  216. package/workspace/FileViewer.d.ts.map +1 -1
  217. package/workspace/FileViewer.js +55 -5
  218. package/workspace/FileViewer.js.map +1 -1
  219. package/workspace/WorkspaceFileReader.d.ts +17 -0
  220. package/workspace/WorkspaceFileReader.d.ts.map +1 -1
  221. package/workspace/WorkspaceFileReader.js +18 -0
  222. package/workspace/WorkspaceFileReader.js.map +1 -1
  223. package/workspace/index.d.ts +1 -1
  224. package/workspace/index.d.ts.map +1 -1
  225. package/workspace/index.js +1 -1
  226. package/workspace/index.js.map +1 -1
  227. package/workspace/useWorkspaceEntries.d.ts +11 -0
  228. package/workspace/useWorkspaceEntries.d.ts.map +1 -1
  229. package/workspace/useWorkspaceEntries.js.map +1 -1
  230. package/workspace/useWorkspaceFileContent.d.ts.map +1 -1
  231. package/workspace/useWorkspaceFileContent.js +4 -1
  232. package/workspace/useWorkspaceFileContent.js.map +1 -1
  233. package/workspace/useWorkspaceFileSearch.d.ts.map +1 -1
  234. package/workspace/useWorkspaceFileSearch.js +8 -3
  235. package/workspace/useWorkspaceFileSearch.js.map +1 -1
  236. package/workspace/useWorkspaceFiles.d.ts +2 -1
  237. package/workspace/useWorkspaceFiles.d.ts.map +1 -1
  238. package/workspace/useWorkspaceFiles.js +3 -2
  239. package/workspace/useWorkspaceFiles.js.map +1 -1
  240. package/workspace/workspaceListingCache.d.ts +1 -1
  241. package/workspace/workspaceListingCache.d.ts.map +1 -1
  242. package/workspace/workspaceListingCache.js +20 -7
  243. package/workspace/workspaceListingCache.js.map +1 -1
  244. package/execution/ArtifactCard.d.ts +0 -81
  245. package/execution/ArtifactCard.d.ts.map +0 -1
  246. package/execution/ArtifactCard.js +0 -130
  247. package/execution/ArtifactCard.js.map +0 -1
  248. package/src/execution/ArtifactCard.tsx +0 -320
@@ -0,0 +1,245 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderHook } from "@testing-library/react";
3
+ import { create } from "@bufbuild/protobuf";
4
+ import {
5
+ AgentExecutionSchema,
6
+ AgentExecutionStatusSchema,
7
+ type AgentExecution,
8
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
9
+ import { ApiResourceMetadataSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/metadata_pb";
10
+ import {
11
+ WorkspaceWriteBackSchema,
12
+ WorkspaceWriteBackPhase,
13
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/writeback_pb";
14
+ import type { WorkspaceEntry } from "../../workspace/useWorkspaceEntries";
15
+ import { useWorkspaceReadRefs } from "../useWorkspaceReadRefs";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Fixtures
19
+ // ---------------------------------------------------------------------------
20
+
21
+ function gitEntry(overrides?: Partial<WorkspaceEntry>): WorkspaceEntry {
22
+ return {
23
+ id: "ws-1",
24
+ name: "acme/api",
25
+ type: "git",
26
+ gitUrl: "https://github.com/acme/api",
27
+ gitBranch: "main",
28
+ ...overrides,
29
+ };
30
+ }
31
+
32
+ function localEntry(): WorkspaceEntry {
33
+ return {
34
+ id: "ws-local",
35
+ name: "dev/project",
36
+ type: "local",
37
+ localPath: "/Users/dev/project",
38
+ };
39
+ }
40
+
41
+ function execWithWriteBacks(
42
+ id: string,
43
+ writeBacks: ReadonlyArray<{
44
+ entryName: string;
45
+ commitSha: string;
46
+ phase?: WorkspaceWriteBackPhase;
47
+ }>,
48
+ ): AgentExecution {
49
+ const exec = create(AgentExecutionSchema);
50
+ exec.metadata = create(ApiResourceMetadataSchema, { id });
51
+ exec.status = create(AgentExecutionStatusSchema);
52
+ exec.status.workspaceWriteBacks = writeBacks.map((wb) =>
53
+ create(WorkspaceWriteBackSchema, {
54
+ workspaceEntryName: wb.entryName,
55
+ commitSha: wb.commitSha,
56
+ phase: wb.phase ?? WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_PUSHED,
57
+ }),
58
+ );
59
+ return exec;
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // useWorkspaceReadRefs
64
+ // ---------------------------------------------------------------------------
65
+
66
+ describe("useWorkspaceReadRefs", () => {
67
+ it("returns the same entries reference when there are no write-backs", () => {
68
+ const entries = [gitEntry()];
69
+ const { result } = renderHook(() => useWorkspaceReadRefs([], entries));
70
+ expect(result.current).toBe(entries);
71
+ });
72
+
73
+ it("decorates a single git entry from a write-back with a matching name", () => {
74
+ const entries = [gitEntry({ name: "acme/api" })];
75
+ const executions = [
76
+ execWithWriteBacks("e1", [{ entryName: "acme/api", commitSha: "sha-1" }]),
77
+ ];
78
+
79
+ const { result } = renderHook(() =>
80
+ useWorkspaceReadRefs(executions, entries),
81
+ );
82
+
83
+ expect(result.current[0].readRef).toBe("sha-1");
84
+ });
85
+
86
+ it("applies an empty-name write-back to the lone git entry (single-entry convention)", () => {
87
+ // Single-entry sessions provision with entryName "" — the runner's
88
+ // WriteBackCoordinator writes back under that empty name.
89
+ const entries = [gitEntry()];
90
+ const executions = [
91
+ execWithWriteBacks("e1", [{ entryName: "", commitSha: "sha-solo" }]),
92
+ ];
93
+
94
+ const { result } = renderHook(() =>
95
+ useWorkspaceReadRefs(executions, entries),
96
+ );
97
+
98
+ expect(result.current[0].readRef).toBe("sha-solo");
99
+ });
100
+
101
+ it("claims a lone mismatched name for a lone git entry, but never for multiple entries", () => {
102
+ const solo = renderHook(() =>
103
+ useWorkspaceReadRefs(
104
+ [execWithWriteBacks("e1", [{ entryName: "other-name", commitSha: "sha-x" }])],
105
+ [gitEntry()],
106
+ ),
107
+ );
108
+ expect(solo.result.current[0].readRef).toBe("sha-x");
109
+
110
+ // With two git entries an unmatched name is ambiguous — decorate nothing.
111
+ const multi = renderHook(() =>
112
+ useWorkspaceReadRefs(
113
+ [execWithWriteBacks("e1", [{ entryName: "other-name", commitSha: "sha-x" }])],
114
+ [
115
+ gitEntry({ id: "ws-1", name: "acme/api" }),
116
+ gitEntry({ id: "ws-2", name: "acme/web" }),
117
+ ],
118
+ ),
119
+ );
120
+ expect(multi.result.current[0].readRef).toBeUndefined();
121
+ expect(multi.result.current[1].readRef).toBeUndefined();
122
+ });
123
+
124
+ it("the latest non-empty SHA wins across executions in order", () => {
125
+ const entries = [gitEntry({ name: "acme/api" })];
126
+ const executions = [
127
+ execWithWriteBacks("e1", [{ entryName: "acme/api", commitSha: "sha-old" }]),
128
+ execWithWriteBacks("e2", [{ entryName: "acme/api", commitSha: "sha-new" }]),
129
+ ];
130
+
131
+ const { result } = renderHook(() =>
132
+ useWorkspaceReadRefs(executions, entries),
133
+ );
134
+
135
+ expect(result.current[0].readRef).toBe("sha-new");
136
+ });
137
+
138
+ it("a FAILED write-back with no SHA never regresses an earlier successful ref", () => {
139
+ const entries = [gitEntry({ name: "acme/api" })];
140
+ const executions = [
141
+ execWithWriteBacks("e1", [{ entryName: "acme/api", commitSha: "sha-good" }]),
142
+ execWithWriteBacks("e2", [
143
+ {
144
+ entryName: "acme/api",
145
+ commitSha: "",
146
+ phase: WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_FAILED,
147
+ },
148
+ ]),
149
+ ];
150
+
151
+ const { result } = renderHook(() =>
152
+ useWorkspaceReadRefs(executions, entries),
153
+ );
154
+
155
+ expect(result.current[0].readRef).toBe("sha-good");
156
+ });
157
+
158
+ it("returns entries unchanged when only FAILED no-SHA write-backs exist", () => {
159
+ const entries = [gitEntry()];
160
+ const executions = [
161
+ execWithWriteBacks("e1", [
162
+ {
163
+ entryName: "",
164
+ commitSha: "",
165
+ phase: WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_FAILED,
166
+ },
167
+ ]),
168
+ ];
169
+
170
+ const { result } = renderHook(() =>
171
+ useWorkspaceReadRefs(executions, entries),
172
+ );
173
+
174
+ expect(result.current).toBe(entries);
175
+ });
176
+
177
+ it("matches strictly by name in multi-entry sessions", () => {
178
+ const entries = [
179
+ gitEntry({ id: "ws-1", name: "frontend" }),
180
+ gitEntry({ id: "ws-2", name: "backend" }),
181
+ ];
182
+ const executions = [
183
+ execWithWriteBacks("e1", [
184
+ { entryName: "frontend", commitSha: "sha-fe" },
185
+ { entryName: "backend", commitSha: "sha-be" },
186
+ ]),
187
+ ];
188
+
189
+ const { result } = renderHook(() =>
190
+ useWorkspaceReadRefs(executions, entries),
191
+ );
192
+
193
+ expect(result.current[0].readRef).toBe("sha-fe");
194
+ expect(result.current[1].readRef).toBe("sha-be");
195
+ });
196
+
197
+ it("never decorates local entries", () => {
198
+ const entries = [localEntry()];
199
+ const executions = [
200
+ execWithWriteBacks("e1", [{ entryName: "", commitSha: "sha-1" }]),
201
+ ];
202
+
203
+ const { result } = renderHook(() =>
204
+ useWorkspaceReadRefs(executions, entries),
205
+ );
206
+
207
+ expect(result.current[0].readRef).toBeUndefined();
208
+ expect(result.current).toBe(entries);
209
+ });
210
+
211
+ it("returns a stable array across streaming frames that change no SHA (DD-010)", () => {
212
+ const entries = [gitEntry({ name: "acme/api" })];
213
+ const { result, rerender } = renderHook(
214
+ (props: { executions: readonly AgentExecution[] }) =>
215
+ useWorkspaceReadRefs(props.executions, entries),
216
+ {
217
+ initialProps: {
218
+ executions: [
219
+ execWithWriteBacks("e1", [{ entryName: "acme/api", commitSha: "sha-1" }]),
220
+ ] as readonly AgentExecution[],
221
+ },
222
+ },
223
+ );
224
+
225
+ const first = result.current;
226
+ expect(first[0].readRef).toBe("sha-1");
227
+
228
+ // A new executions array (fresh identity, e.g. a streaming frame) with the
229
+ // same SHA must return the identical decorated array.
230
+ rerender({
231
+ executions: [
232
+ execWithWriteBacks("e1", [{ entryName: "acme/api", commitSha: "sha-1" }]),
233
+ ],
234
+ });
235
+ expect(result.current).toBe(first);
236
+
237
+ // Advancing the SHA produces a new decoration.
238
+ rerender({
239
+ executions: [
240
+ execWithWriteBacks("e1", [{ entryName: "acme/api", commitSha: "sha-2" }]),
241
+ ],
242
+ });
243
+ expect(result.current[0].readRef).toBe("sha-2");
244
+ });
245
+ });
@@ -0,0 +1,23 @@
1
+ // Identity of the session's artifact document tabs in the workspace surface's
2
+ // editor group.
3
+ //
4
+ // Kept in its own module (not in ArtifactDocument.tsx) so behavior-only
5
+ // consumers — useSessionPanel opens an artifact tab, SessionViewer routes to it
6
+ // — depend on the identity without pulling the component (and its content
7
+ // renderer) into their module graph (DD-003 headless-first). Mirrors
8
+ // plan-document.ts.
9
+
10
+ import { virtualEntryId } from "../internal/store/index.js";
11
+
12
+ /**
13
+ * Virtual entry id of the artifact document FAMILY. Unlike the plan (one tab
14
+ * per session), artifacts are many: every opened artifact shares this one
15
+ * entry id and is distinguished by its {@link artifactKey}-derived `path`, so
16
+ * `editorKey(ARTIFACT_DOCUMENT_ENTRY_ID, path)` yields one tab per artifact —
17
+ * the VS Code "each file is a tab" model. The NUL-namespaced virtual id can
18
+ * never alias a real workspace entry, and its distinct `"artifact"` kind can
19
+ * never collide with the plan tab's `"plan"` kind.
20
+ *
21
+ * @see artifactKey — the `path` component (an artifact's stable identity)
22
+ */
23
+ export const ARTIFACT_DOCUMENT_ENTRY_ID = virtualEntryId("artifact");
@@ -4,10 +4,11 @@ import { useCallback, useState } from "react";
4
4
  import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
5
5
  import {
6
6
  useSessionArtifacts,
7
+ artifactKey,
7
8
  type SessionArtifactEntry,
8
9
  } from "../useSessionArtifacts.js";
9
- import { ArtifactCard } from "../../execution/ArtifactCard.js";
10
10
  import { ArtifactPreviewModal } from "../../execution/ArtifactPreviewModal.js";
11
+ import { ArtifactRow } from "../../execution/ArtifactRow.js";
11
12
  import { isPlanArtifact } from "../../library/detect-plan-artifact.js";
12
13
  import type { ApplyResourceResult } from "../../library/useApplyResource.js";
13
14
 
@@ -29,14 +30,39 @@ export interface ArtifactsTabProps {
29
30
  * every artifact.
30
31
  */
31
32
  readonly onOpenPlan?: (executionId: string) => void;
33
+ /**
34
+ * Open an artifact as an editor-pane document (the session panel's
35
+ * artifact-family virtual-document tab — the VS Code "each file is a tab"
36
+ * model). When provided, clicking a non-plan artifact routes here instead of
37
+ * the preview modal. When omitted (panel-less hosts) the modal remains the
38
+ * open mechanism. Plan artifacts always defer to {@link onOpenPlan} first.
39
+ */
40
+ readonly onOpenArtifact?: (entry: SessionArtifactEntry) => void;
41
+ /**
42
+ * Pin a non-plan artifact's document tab — the double-click half of the
43
+ * open/activate split (mirrors the file tree's `onOpenFile`/`onActivateFile`).
44
+ * When provided, each row binds a double-click that promotes the preview the
45
+ * single click opened. Omit in panel-less/modal hosts: with no handler the
46
+ * row binds no double-click, so a double-click is just two harmless single
47
+ * clicks. Plans always route to {@link onOpenPlan} (their tab is already
48
+ * pinned), so double-click == single-click for a plan.
49
+ */
50
+ readonly onActivateArtifact?: (entry: SessionArtifactEntry) => void;
32
51
  }
33
52
 
34
53
  /**
35
54
  * Artifacts facet for the session panel (a `useSessionRailViews` rail view).
36
55
  *
37
- * Wraps `useSessionArtifacts` `ArtifactCard` list with
38
- * `ArtifactPreviewModal` the same content as `ArtifactsWidget`
39
- * without the section heading.
56
+ * A VS Code-style dense file list of the session's outputs: one compact
57
+ * {@link ArtifactRow} per artifact (shared file-type icon + name + size, with a
58
+ * hover/focus Download). Clicking a row opens the artifact — a `plan.md` in the
59
+ * plan document tab ({@link onOpenPlan}), any other artifact in an editor-pane
60
+ * document ({@link onOpenArtifact}), or, for panel-less hosts that inject
61
+ * neither, the {@link ArtifactPreviewModal} popup fallback.
62
+ *
63
+ * Deliberately metadata-only: rows do NOT fetch content for resource detection
64
+ * (that would be one request per artifact on open) — detection and the
65
+ * Apply/Push action live in the opened document/modal instead.
40
66
  */
41
67
  export function ArtifactsTab({
42
68
  executions,
@@ -44,20 +70,41 @@ export function ArtifactsTab({
44
70
  onApplied,
45
71
  onImplementPlan,
46
72
  onOpenPlan,
73
+ onOpenArtifact,
74
+ onActivateArtifact,
47
75
  }: ArtifactsTabProps) {
48
76
  const { artifacts, hasArtifacts } = useSessionArtifacts(executions);
49
77
  const [previewEntry, setPreviewEntry] = useState<SessionArtifactEntry | null>(null);
50
78
 
51
- const handlePreview = useCallback(
79
+ const handleOpen = useCallback(
52
80
  (entry: SessionArtifactEntry) => {
53
- // The plan opens as a document (the panel's plan tab), never a popup.
81
+ // A plan is a first-class document — it opens in the dedicated plan tab,
82
+ // never as a generic artifact or a popup.
54
83
  if (onOpenPlan && isPlanArtifact(entry.artifact)) {
55
84
  onOpenPlan(entry.executionId);
56
85
  return;
57
86
  }
87
+ // Editor-pane document when the host injected it; otherwise the modal.
88
+ if (onOpenArtifact) {
89
+ onOpenArtifact(entry);
90
+ return;
91
+ }
58
92
  setPreviewEntry(entry);
59
93
  },
60
- [onOpenPlan],
94
+ [onOpenPlan, onOpenArtifact],
95
+ );
96
+
97
+ const handleActivate = useCallback(
98
+ (entry: SessionArtifactEntry) => {
99
+ // A plan's tab is always pinned, so double-click matches single-click:
100
+ // route to the plan tab, never pin a generic artifact.
101
+ if (onOpenPlan && isPlanArtifact(entry.artifact)) {
102
+ onOpenPlan(entry.executionId);
103
+ return;
104
+ }
105
+ onActivateArtifact?.(entry);
106
+ },
107
+ [onOpenPlan, onActivateArtifact],
61
108
  );
62
109
 
63
110
  const handleClosePreview = useCallback(() => {
@@ -76,19 +123,20 @@ export function ArtifactsTab({
76
123
 
77
124
  return (
78
125
  <>
79
- <div role="list" className="space-y-2">
126
+ <ul role="list" className="flex flex-col">
80
127
  {artifacts.map((entry) => (
81
- <div key={entry.artifact.sandboxPath || entry.artifact.name} role="listitem">
82
- <ArtifactCard
83
- artifact={entry.artifact}
84
- executionId={entry.executionId}
85
- org={org}
86
- hasNameCollision={entry.hasNameCollision}
87
- onPreview={() => handlePreview(entry)}
88
- />
89
- </div>
128
+ <ArtifactRow
129
+ key={artifactKey(entry.artifact)}
130
+ artifact={entry.artifact}
131
+ executionId={entry.executionId}
132
+ hasNameCollision={entry.hasNameCollision}
133
+ onOpen={() => handleOpen(entry)}
134
+ onActivate={
135
+ onActivateArtifact ? () => handleActivate(entry) : undefined
136
+ }
137
+ />
90
138
  ))}
91
- </div>
139
+ </ul>
92
140
 
93
141
  {previewEntry && (
94
142
  <ArtifactPreviewModal
@@ -9,7 +9,8 @@ import {
9
9
  ExecutionPhase,
10
10
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
11
11
  import { StigmerContext } from "../../../context";
12
- import { ArtifactsTab } from "../ArtifactsTab";
12
+ import { ArtifactsTab, type ArtifactsTabProps } from "../ArtifactsTab";
13
+ import type { SessionArtifactEntry } from "../../useSessionArtifacts";
13
14
 
14
15
  function artifact(name: string) {
15
16
  return create(ExecutionArtifactSchema, {
@@ -38,86 +39,149 @@ function createStigmerMock(): Stigmer {
38
39
  } as unknown as Stigmer;
39
40
  }
40
41
 
41
- function renderTab(
42
- onImplementPlan?: () => void,
43
- onOpenPlan?: (executionId: string) => void,
44
- ) {
42
+ function renderTab(props: Partial<ArtifactsTabProps> = {}) {
45
43
  return render(
46
44
  <StigmerContext.Provider value={createStigmerMock()}>
47
- <ArtifactsTab
48
- executions={[execution]}
49
- org="acme"
50
- onImplementPlan={onImplementPlan}
51
- onOpenPlan={onOpenPlan}
52
- />
45
+ <ArtifactsTab executions={[execution]} org="acme" {...props} />
53
46
  </StigmerContext.Provider>,
54
47
  );
55
48
  }
56
49
 
57
- function openPreviewFor(name: string) {
58
- const row = screen.getByText(name).closest("[role='listitem']") as HTMLElement;
59
- fireEvent.click(within(row).getByText("Preview"));
50
+ /** Click the open target of a row by its artifact name. */
51
+ function openRow(name: string) {
52
+ fireEvent.click(screen.getByText(name));
53
+ }
54
+
55
+ /** Double-click the open target of a row by its artifact name (the pin gesture). */
56
+ function activateRow(name: string) {
57
+ fireEvent.doubleClick(screen.getByText(name));
60
58
  }
61
59
 
62
60
  afterEach(cleanup);
63
61
 
64
- describe("ArtifactsTab — plan 'Build' wiring", () => {
65
- it("shows 'Build' in the preview of a plan.md artifact", () => {
66
- renderTab(vi.fn());
62
+ describe("ArtifactsTab — dense row list", () => {
63
+ it("renders one row per artifact with a Download control (no old 'Preview' button)", () => {
64
+ renderTab({ onOpenArtifact: vi.fn() });
67
65
 
68
- openPreviewFor("plan.md");
66
+ expect(screen.getAllByRole("listitem")).toHaveLength(2);
67
+ expect(screen.getByText("plan.md")).toBeTruthy();
68
+ expect(screen.getByText("notes.md")).toBeTruthy();
69
+ expect(screen.queryByText("Preview")).toBeNull();
70
+ expect(screen.getByLabelText("Download plan.md")).toBeTruthy();
71
+ expect(screen.getByLabelText("Download notes.md")).toBeTruthy();
72
+ });
69
73
 
70
- const dialog = document.querySelector("dialog")!;
71
- expect(within(dialog).getByText("Build")).toBeTruthy();
74
+ it("renders the empty state with no artifacts", () => {
75
+ render(
76
+ <StigmerContext.Provider value={createStigmerMock()}>
77
+ <ArtifactsTab executions={[]} org="acme" />
78
+ </StigmerContext.Provider>,
79
+ );
80
+ expect(screen.getByText(/No artifacts yet/)).toBeTruthy();
72
81
  });
82
+ });
73
83
 
74
- it("does not show 'Build' in the preview of a non-plan artifact", () => {
75
- renderTab(vi.fn());
84
+ describe("ArtifactsTab open routing", () => {
85
+ it("routes a non-plan artifact to onOpenArtifact (no modal) when provided", () => {
86
+ const onOpenArtifact = vi.fn<(entry: SessionArtifactEntry) => void>();
87
+ renderTab({ onOpenArtifact, onOpenPlan: vi.fn() });
76
88
 
77
- openPreviewFor("notes.md");
89
+ openRow("notes.md");
78
90
 
79
- const dialog = document.querySelector("dialog")!;
80
- expect(within(dialog).queryByText("Build")).toBeNull();
91
+ expect(onOpenArtifact).toHaveBeenCalledTimes(1);
92
+ expect(onOpenArtifact.mock.calls[0][0].artifact.name).toBe("notes.md");
93
+ expect(document.querySelector("dialog")).toBeNull();
81
94
  });
82
95
 
83
- it("invokes onImplementPlan when 'Build' is clicked for a plan", () => {
84
- const onImplementPlan = vi.fn();
85
- renderTab(onImplementPlan);
96
+ it("routes plan.md to onOpenPlan (never onOpenArtifact) when both are provided", () => {
97
+ const onOpenArtifact = vi.fn();
98
+ const onOpenPlan = vi.fn();
99
+ renderTab({ onOpenArtifact, onOpenPlan });
86
100
 
87
- openPreviewFor("plan.md");
88
- const dialog = document.querySelector("dialog")!;
89
- fireEvent.click(within(dialog).getByText("Build"));
101
+ openRow("plan.md");
90
102
 
91
- expect(onImplementPlan).toHaveBeenCalledTimes(1);
103
+ expect(onOpenPlan).toHaveBeenCalledWith("aex_1");
104
+ expect(onOpenArtifact).not.toHaveBeenCalled();
105
+ expect(document.querySelector("dialog")).toBeNull();
106
+ });
107
+
108
+ it("falls back to the modal for a non-plan artifact when onOpenArtifact is omitted", () => {
109
+ renderTab({ onOpenPlan: vi.fn() });
110
+
111
+ openRow("notes.md");
112
+
113
+ expect(document.querySelector("dialog")).toBeTruthy();
114
+ });
115
+
116
+ it("falls back to the modal for plan.md when neither onOpenPlan nor onOpenArtifact is provided", () => {
117
+ renderTab();
118
+
119
+ openRow("plan.md");
120
+
121
+ expect(document.querySelector("dialog")).toBeTruthy();
92
122
  });
93
123
  });
94
124
 
95
- describe("ArtifactsTab — plan routes to the plan document tab", () => {
96
- it("routes a plan.md preview to onOpenPlan (no modal) when provided", () => {
125
+ describe("ArtifactsTab — pin (double-click) routing", () => {
126
+ it("routes a non-plan double-click to onActivateArtifact (pin)", () => {
127
+ const onActivateArtifact = vi.fn<(entry: SessionArtifactEntry) => void>();
128
+ renderTab({ onOpenArtifact: vi.fn(), onActivateArtifact, onOpenPlan: vi.fn() });
129
+
130
+ activateRow("notes.md");
131
+
132
+ expect(onActivateArtifact).toHaveBeenCalledTimes(1);
133
+ expect(onActivateArtifact.mock.calls[0][0].artifact.name).toBe("notes.md");
134
+ });
135
+
136
+ it("routes a plan.md double-click to onOpenPlan (its tab is already pinned), never onActivateArtifact", () => {
137
+ const onActivateArtifact = vi.fn();
97
138
  const onOpenPlan = vi.fn();
98
- renderTab(vi.fn(), onOpenPlan);
139
+ renderTab({ onOpenArtifact: vi.fn(), onActivateArtifact, onOpenPlan });
99
140
 
100
- openPreviewFor("plan.md");
141
+ activateRow("plan.md");
101
142
 
102
143
  expect(onOpenPlan).toHaveBeenCalledWith("aex_1");
103
- expect(document.querySelector("dialog")).toBeNull();
144
+ expect(onActivateArtifact).not.toHaveBeenCalled();
104
145
  });
105
146
 
106
- it("keeps the modal for non-plan artifacts even when onOpenPlan is provided", () => {
107
- const onOpenPlan = vi.fn();
108
- renderTab(vi.fn(), onOpenPlan);
147
+ it("binds no double-click when onActivateArtifact is omitted (panel-less host) a double-click is harmless", () => {
148
+ const onOpenArtifact = vi.fn();
149
+ renderTab({ onOpenArtifact });
109
150
 
110
- openPreviewFor("notes.md");
151
+ // With no pin handler the row binds no onDoubleClick, so a dblclick fires
152
+ // nothing (in a real browser the leading single-clicks still preview).
153
+ expect(() => activateRow("notes.md")).not.toThrow();
154
+ expect(onOpenArtifact).not.toHaveBeenCalled();
155
+ });
156
+ });
111
157
 
112
- expect(onOpenPlan).not.toHaveBeenCalled();
113
- expect(document.querySelector("dialog")).toBeTruthy();
158
+ describe("ArtifactsTab — modal fallback plan 'Build' wiring", () => {
159
+ it("shows 'Build' in the modal preview of a plan.md artifact (panel-less host)", () => {
160
+ renderTab({ onImplementPlan: vi.fn() });
161
+
162
+ openRow("plan.md");
163
+
164
+ const dialog = document.querySelector("dialog")!;
165
+ expect(within(dialog).getByText("Build")).toBeTruthy();
114
166
  });
115
167
 
116
- it("keeps the modal for plan.md when onOpenPlan is omitted (hosts without the plan tab)", () => {
117
- renderTab(vi.fn());
168
+ it("does not show 'Build' in the modal preview of a non-plan artifact", () => {
169
+ renderTab({ onImplementPlan: vi.fn() });
118
170
 
119
- openPreviewFor("plan.md");
171
+ openRow("notes.md");
120
172
 
121
- expect(document.querySelector("dialog")).toBeTruthy();
173
+ const dialog = document.querySelector("dialog")!;
174
+ expect(within(dialog).queryByText("Build")).toBeNull();
175
+ });
176
+
177
+ it("invokes onImplementPlan when 'Build' is clicked for a plan", () => {
178
+ const onImplementPlan = vi.fn();
179
+ renderTab({ onImplementPlan });
180
+
181
+ openRow("plan.md");
182
+ const dialog = document.querySelector("dialog")!;
183
+ fireEvent.click(within(dialog).getByText("Build"));
184
+
185
+ expect(onImplementPlan).toHaveBeenCalledTimes(1);
122
186
  });
123
187
  });
@@ -33,7 +33,7 @@ export type {
33
33
  UseSessionConversationReturn,
34
34
  } from "./useSessionConversation.js";
35
35
 
36
- export { useSessionArtifacts } from "./useSessionArtifacts.js";
36
+ export { useSessionArtifacts, artifactKey } from "./useSessionArtifacts.js";
37
37
  export type {
38
38
  SessionArtifactEntry,
39
39
  UseSessionArtifactsReturn,
@@ -45,6 +45,8 @@ export type {
45
45
  UseSessionWriteBacksReturn,
46
46
  } from "./useSessionWriteBacks.js";
47
47
 
48
+ export { useWorkspaceReadRefs } from "./useWorkspaceReadRefs.js";
49
+
48
50
  export { useSessionFileChanges } from "./useSessionFileChanges.js";
49
51
  export type { UseSessionFileChangesReturn } from "./useSessionFileChanges.js";
50
52
 
@@ -127,6 +129,10 @@ export {
127
129
  PLAN_DOCUMENT_PATH,
128
130
  } from "./plan-document.js";
129
131
 
132
+ // Artifact document — the editor-area rendering of an execution artifact
133
+ // (mounted as an artifact-family virtual document) and the identity of its tabs.
134
+ export { ARTIFACT_DOCUMENT_ENTRY_ID } from "./artifact-document.js";
135
+
130
136
  // Selection types (for platform builders composing their own Inspect facet)
131
137
  export type { SelectedThreadItem } from "../internal/store/selection-store.js";
132
138
 
@@ -41,11 +41,22 @@ export interface UseSessionArtifactsReturn {
41
41
  }
42
42
 
43
43
  /**
44
- * Returns the dedup key for an artifact. Uses `sandbox_path` when
45
- * available (stable filesystem identity within the session sandbox),
46
- * falling back to `name` for older artifacts that predate the field.
44
+ * The stable identity of an artifact within a session. Uses `sandbox_path`
45
+ * when available (the artifact's filesystem identity within the session
46
+ * sandbox), falling back to `name` for older artifacts that predate the field.
47
+ *
48
+ * This is the SINGLE source of truth for artifact identity across three sites
49
+ * that must agree or a tab could fail to resolve its artifact:
50
+ * 1. deduplication here (latest execution wins per key),
51
+ * 2. the virtual-document tab `path` when an artifact opens in the editor area
52
+ * (see `SessionViewer` / `useSessionPanel.openArtifact`), and
53
+ * 3. the `artifactKey → entry` lookup that renders the open tab's document.
54
+ *
55
+ * Its basename doubles as the editor tab's label (paths render as their last
56
+ * `/` segment in `EditorTabs`), so `sandbox_path` gives a natural filename
57
+ * label with cross-directory disambiguation for free.
47
58
  */
48
- function dedupKey(artifact: ExecutionArtifact): string {
59
+ export function artifactKey(artifact: ExecutionArtifact): string {
49
60
  return artifact.sandboxPath || artifact.name;
50
61
  }
51
62
 
@@ -102,7 +113,7 @@ export function useSessionArtifacts(
102
113
  const terminal = isTerminalPhase(phase);
103
114
 
104
115
  for (const artifact of execution.status?.artifacts ?? []) {
105
- const key = dedupKey(artifact);
116
+ const key = artifactKey(artifact);
106
117
  entryMap.set(key, {
107
118
  artifact,
108
119
  executionId,