@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
@@ -1,8 +1,15 @@
1
1
  import { describe, it, expect, afterEach } from "vitest";
2
2
  import { renderHook, act, cleanup } from "@testing-library/react";
3
- import { ExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
3
+ import { create } from "@bufbuild/protobuf";
4
+ import {
5
+ ExecutionArtifactKind,
6
+ ExecutionPhase,
7
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
8
+ import { ExecutionArtifactSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/artifact_pb";
4
9
  import { useSessionPanel, type UseSessionPanelOptions } from "../useSessionPanel";
5
10
  import { PLAN_DOCUMENT_ENTRY_ID, PLAN_DOCUMENT_PATH } from "../plan-document";
11
+ import { ARTIFACT_DOCUMENT_ENTRY_ID } from "../artifact-document";
12
+ import { artifactKey } from "../useSessionArtifacts";
6
13
  import type { SelectedThreadItem } from "../../internal/store/selection-store";
7
14
 
8
15
  // ---------------------------------------------------------------------------
@@ -255,6 +262,68 @@ describe("useSessionPanel — plan document tab", () => {
255
262
  });
256
263
  });
257
264
 
265
+ describe("useSessionPanel — artifact document tabs", () => {
266
+ function artifact(sandboxPath: string, name: string) {
267
+ return create(ExecutionArtifactSchema, {
268
+ name,
269
+ kind: ExecutionArtifactKind.FILE,
270
+ sandboxPath,
271
+ storageKey: `artifacts/aex_1/${name}`,
272
+ });
273
+ }
274
+
275
+ it("openArtifact opens the panel with a PREVIEW tab keyed by artifactKey", () => {
276
+ const { result } = renderPanel();
277
+ const a = artifact(".stigmer/notes.md", "notes.md");
278
+ act(() => result.current.openArtifact(a));
279
+
280
+ expect(result.current.isOpen).toBe(true);
281
+ expect(result.current.editorsStore.getSnapshot().editors).toEqual([
282
+ { entryId: ARTIFACT_DOCUMENT_ENTRY_ID, path: artifactKey(a), preview: true },
283
+ ]);
284
+ });
285
+
286
+ it("reuses the single preview slot as different artifacts are browsed", () => {
287
+ const { result } = renderPanel();
288
+ act(() => result.current.openArtifact(artifact(".stigmer/a.md", "a.md")));
289
+ act(() => result.current.openArtifact(artifact(".stigmer/b.md", "b.md")));
290
+
291
+ // VS Code preview semantics: single-click browsing reuses one tab.
292
+ expect(result.current.editorsStore.getSnapshot().editors).toEqual([
293
+ { entryId: ARTIFACT_DOCUMENT_ENTRY_ID, path: ".stigmer/b.md", preview: true },
294
+ ]);
295
+ });
296
+
297
+ it("does not evict a pinned plan tab when an artifact opens", () => {
298
+ const { result } = renderPanel();
299
+ act(() => result.current.openPlanDocument());
300
+ act(() => result.current.openArtifact(artifact(".stigmer/a.md", "a.md")));
301
+
302
+ expect(result.current.editorsStore.getSnapshot().editors).toEqual([
303
+ { entryId: PLAN_DOCUMENT_ENTRY_ID, path: PLAN_DOCUMENT_PATH, preview: false },
304
+ { entryId: ARTIFACT_DOCUMENT_ENTRY_ID, path: ".stigmer/a.md", preview: true },
305
+ ]);
306
+ });
307
+
308
+ it("pinArtifact promotes an open artifact preview to a persistent tab", () => {
309
+ const { result } = renderPanel();
310
+ const a = artifact(".stigmer/notes.md", "notes.md");
311
+ // The single-click open leaves a preview tab; the double-click pins it.
312
+ act(() => result.current.openArtifact(a));
313
+ act(() => result.current.pinArtifact(a));
314
+
315
+ expect(result.current.editorsStore.getSnapshot().editors).toEqual([
316
+ { entryId: ARTIFACT_DOCUMENT_ENTRY_ID, path: artifactKey(a), preview: false },
317
+ ]);
318
+ });
319
+
320
+ it("pinArtifact is a no-op when the artifact is not open (mirrors the store's generic pin)", () => {
321
+ const { result } = renderPanel();
322
+ act(() => result.current.pinArtifact(artifact(".stigmer/x.md", "x.md")));
323
+ expect(result.current.editorsStore.getSnapshot().editors).toEqual([]);
324
+ });
325
+ });
326
+
258
327
  describe("useSessionPanel — defaultView (home view)", () => {
259
328
  it("seeds the view to the provided defaultView (launcher homes on Config)", () => {
260
329
  const { result } = renderPanel({ defaultView: "configure" });
@@ -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,
@@ -127,6 +127,10 @@ export {
127
127
  PLAN_DOCUMENT_PATH,
128
128
  } from "./plan-document.js";
129
129
 
130
+ // Artifact document — the editor-area rendering of an execution artifact
131
+ // (mounted as an artifact-family virtual document) and the identity of its tabs.
132
+ export { ARTIFACT_DOCUMENT_ENTRY_ID } from "./artifact-document.js";
133
+
130
134
  // Selection types (for platform builders composing their own Inspect facet)
131
135
  export type { SelectedThreadItem } from "../internal/store/selection-store.js";
132
136
 
@@ -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,
@@ -117,6 +117,15 @@ export interface SendFollowUpOptions {
117
117
  * @see {@link CreateAgentExecutionInput.workspaceFileRefs}
118
118
  */
119
119
  readonly workspaceFileRefs?: string[];
120
+ /**
121
+ * ID of the execution this follow-up supersedes (edit-and-resubmit).
122
+ *
123
+ * The conversation read model hides the superseded execution so the
124
+ * edited message replaces the original in place.
125
+ *
126
+ * @see {@link CreateAgentExecutionInput.supersedesExecutionId}
127
+ */
128
+ readonly supersedesExecutionId?: string;
120
129
  }
121
130
 
122
131
  /**
@@ -129,7 +138,14 @@ export interface SendFollowUpOptions {
129
138
  export interface UseSessionConversationReturn {
130
139
  /** The session object, or null while loading. */
131
140
  readonly session: Session | null;
132
- /** Executions in terminal phases, in chronological order. */
141
+ /**
142
+ * Executions in terminal phases, in chronological order.
143
+ *
144
+ * Excludes turns replaced via edit-and-resubmit (an execution whose id
145
+ * appears as another execution's `supersedesExecutionId`) — the edited
146
+ * message stands in the original's place. Superseded records remain in
147
+ * execution history surfaces; only the conversation view hides them.
148
+ */
133
149
  readonly completedExecutions: readonly AgentExecution[];
134
150
  /** Currently streaming execution (stream or fetch fallback), or null. */
135
151
  readonly activeStreamExecution: AgentExecution | null;
@@ -491,12 +507,37 @@ export function useSessionConversation(
491
507
  }
492
508
  }, [activeExecutionId, stream.phase, refetch]);
493
509
 
510
+ // Executions replaced via edit-and-resubmit. The successor carries
511
+ // `spec.supersedes_execution_id`; hiding the superseded turn makes the
512
+ // edited message read as a single corrected exchange (in-place replace).
513
+ // The link must also be read off the live stream copy — right after a
514
+ // resubmit, the successor streams before the list refetch delivers it.
515
+ // Display-only: the raw `executions` list (and therefore active-id
516
+ // resolution above) is never filtered.
517
+ //
518
+ // Dep is the scalar link, NOT stream.execution: the stream object changes
519
+ // reference every frame, and rebuilding the Set per frame would hand
520
+ // MessageThread a fresh executions array mid-stream, defeating its
521
+ // memoization (DD-010).
522
+ const streamSupersededId =
523
+ stream.execution?.spec?.supersedesExecutionId || null;
524
+ const supersededIds = useMemo(() => {
525
+ const ids = new Set<string>();
526
+ for (const e of executions) {
527
+ const superseded = e.spec?.supersedesExecutionId;
528
+ if (superseded) ids.add(superseded);
529
+ }
530
+ if (streamSupersededId) ids.add(streamSupersededId);
531
+ return ids;
532
+ }, [executions, streamSupersededId]);
533
+
494
534
  const completedExecutions = useMemo(() => {
495
- if (!activeExecutionId) return executions;
496
- return executions.filter(
497
- (e) => (e.metadata?.id ?? "") !== activeExecutionId,
498
- );
499
- }, [executions, activeExecutionId]);
535
+ return executions.filter((e) => {
536
+ const id = e.metadata?.id ?? "";
537
+ if (activeExecutionId && id === activeExecutionId) return false;
538
+ return !supersededIds.has(id);
539
+ });
540
+ }, [executions, activeExecutionId, supersededIds]);
500
541
 
501
542
  const fetchedActiveExecution = useMemo(() => {
502
543
  if (!activeExecutionId) return null;
@@ -604,6 +645,7 @@ export function useSessionConversation(
604
645
  buildFromPlan: options?.buildFromPlan,
605
646
  autoApproveAll: options?.autoApproveAll,
606
647
  workspaceFileRefs: options?.workspaceFileRefs,
648
+ supersedesExecutionId: options?.supersedesExecutionId,
607
649
  });
608
650
  setPendingExecutionId(result.executionId);
609
651
  refetch();
@@ -410,6 +410,7 @@ export function useSessionPageFlow(
410
410
  // not from the composer (the pre-arm toggle was removed).
411
411
  autoApproveAll: autoApproveAll || undefined,
412
412
  workspaceFileRefs: context?.workspaceFileRefs,
413
+ supersedesExecutionId: context?.supersedesExecutionId,
413
414
  });
414
415
 
415
416
  sessionVariables.clear();