@stigmer/react 3.1.2 → 3.1.3

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 (171) hide show
  1. package/composer/SessionComposer.d.ts +6 -5
  2. package/composer/SessionComposer.d.ts.map +1 -1
  3. package/composer/SessionComposer.js +1 -1
  4. package/composer/SessionComposer.js.map +1 -1
  5. package/execution/ArtifactPreviewModal.d.ts +5 -5
  6. package/execution/ArtifactPreviewModal.js +4 -4
  7. package/execution/ArtifactPreviewModal.js.map +1 -1
  8. package/execution/FileChangeProgressBar.d.ts +47 -0
  9. package/execution/FileChangeProgressBar.d.ts.map +1 -0
  10. package/execution/FileChangeProgressBar.js +49 -0
  11. package/execution/FileChangeProgressBar.js.map +1 -0
  12. package/execution/FileChangesView.d.ts.map +1 -1
  13. package/execution/FileChangesView.js +19 -4
  14. package/execution/FileChangesView.js.map +1 -1
  15. package/execution/FileReviewAtoms.d.ts +27 -0
  16. package/execution/FileReviewAtoms.d.ts.map +1 -0
  17. package/execution/FileReviewAtoms.js +64 -0
  18. package/execution/FileReviewAtoms.js.map +1 -0
  19. package/execution/FileReviewCard.d.ts +2 -2
  20. package/execution/FileReviewCard.d.ts.map +1 -1
  21. package/execution/FileReviewCard.js +32 -62
  22. package/execution/FileReviewCard.js.map +1 -1
  23. package/execution/MessageEntry.d.ts +0 -9
  24. package/execution/MessageEntry.d.ts.map +1 -1
  25. package/execution/MessageEntry.js +1 -11
  26. package/execution/MessageEntry.js.map +1 -1
  27. package/execution/MessageThread.d.ts +3 -4
  28. package/execution/MessageThread.d.ts.map +1 -1
  29. package/execution/MessageThread.js +25 -11
  30. package/execution/MessageThread.js.map +1 -1
  31. package/execution/PlanArtifactCard.d.ts +16 -10
  32. package/execution/PlanArtifactCard.d.ts.map +1 -1
  33. package/execution/PlanArtifactCard.js +31 -7
  34. package/execution/PlanArtifactCard.js.map +1 -1
  35. package/execution/PlanCompletionCard.d.ts +4 -4
  36. package/execution/PlanCompletionCard.d.ts.map +1 -1
  37. package/execution/PlanCompletionCard.js +4 -4
  38. package/execution/PlanCompletionCard.js.map +1 -1
  39. package/execution/PlanStreamingCard.d.ts +7 -2
  40. package/execution/PlanStreamingCard.d.ts.map +1 -1
  41. package/execution/PlanStreamingCard.js +11 -6
  42. package/execution/PlanStreamingCard.js.map +1 -1
  43. package/execution/index.d.ts +4 -0
  44. package/execution/index.d.ts.map +1 -1
  45. package/execution/index.js +2 -0
  46. package/execution/index.js.map +1 -1
  47. package/execution/use-build-from-plan-hotkey.d.ts +1 -1
  48. package/execution/use-build-from-plan-hotkey.js +1 -1
  49. package/execution/useArtifactCopy.d.ts +51 -0
  50. package/execution/useArtifactCopy.d.ts.map +1 -0
  51. package/execution/useArtifactCopy.js +75 -0
  52. package/execution/useArtifactCopy.js.map +1 -0
  53. package/execution/useArtifactDownload.d.ts.map +1 -1
  54. package/execution/useArtifactDownload.js +17 -5
  55. package/execution/useArtifactDownload.js.map +1 -1
  56. package/execution/useCreateAgentExecution.d.ts +5 -3
  57. package/execution/useCreateAgentExecution.d.ts.map +1 -1
  58. package/execution/useCreateAgentExecution.js.map +1 -1
  59. package/index.d.ts +3 -3
  60. package/index.d.ts.map +1 -1
  61. package/index.js +2 -2
  62. package/index.js.map +1 -1
  63. package/internal/file-tree/FileTreeNode.d.ts.map +1 -1
  64. package/internal/file-tree/FileTreeNode.js +2 -2
  65. package/internal/file-tree/FileTreeNode.js.map +1 -1
  66. package/library/detect-plan-artifact.d.ts +27 -9
  67. package/library/detect-plan-artifact.d.ts.map +1 -1
  68. package/library/detect-plan-artifact.js +26 -6
  69. package/library/detect-plan-artifact.js.map +1 -1
  70. package/library/index.d.ts +1 -1
  71. package/library/index.d.ts.map +1 -1
  72. package/library/index.js +1 -1
  73. package/library/index.js.map +1 -1
  74. package/package.json +4 -4
  75. package/session/PlanEditor.js +1 -1
  76. package/session/PlanEditor.js.map +1 -1
  77. package/session/SessionViewer.d.ts.map +1 -1
  78. package/session/SessionViewer.js +32 -13
  79. package/session/SessionViewer.js.map +1 -1
  80. package/session/plan-document.d.ts +14 -2
  81. package/session/plan-document.d.ts.map +1 -1
  82. package/session/plan-document.js +14 -3
  83. package/session/plan-document.js.map +1 -1
  84. package/session/usePlanDraft.d.ts +1 -1
  85. package/session/useSessionConversation.d.ts +9 -2
  86. package/session/useSessionConversation.d.ts.map +1 -1
  87. package/session/useSessionConversation.js +19 -2
  88. package/session/useSessionConversation.js.map +1 -1
  89. package/src/composer/SessionComposer.tsx +7 -6
  90. package/src/execution/ArtifactPreviewModal.tsx +9 -9
  91. package/src/execution/FileChangeProgressBar.tsx +121 -0
  92. package/src/execution/FileChangesView.tsx +18 -4
  93. package/src/execution/FileReviewAtoms.tsx +89 -0
  94. package/src/execution/FileReviewCard.tsx +57 -90
  95. package/src/execution/MessageEntry.tsx +0 -62
  96. package/src/execution/MessageThread.tsx +29 -15
  97. package/src/execution/PlanArtifactCard.tsx +130 -52
  98. package/src/execution/PlanCompletionCard.tsx +5 -5
  99. package/src/execution/PlanStreamingCard.tsx +15 -11
  100. package/src/execution/__tests__/ArtifactPreviewModal.test.tsx +6 -6
  101. package/src/execution/__tests__/FileChangeProgressBar.test.tsx +125 -0
  102. package/src/execution/__tests__/FileReviewCard.test.tsx +66 -7
  103. package/src/execution/__tests__/MessageThread.test.tsx +1 -1
  104. package/src/execution/__tests__/PlanArtifactCard.test.tsx +52 -18
  105. package/src/execution/__tests__/PlanCompletionCard.test.tsx +2 -2
  106. package/src/execution/__tests__/PlanStreamingCard.test.tsx +11 -4
  107. package/src/execution/__tests__/buildThreadItems-plan-completion.test.ts +104 -8
  108. package/src/execution/__tests__/message-entry.test.tsx +6 -37
  109. package/src/execution/__tests__/useArtifactCopy.test.tsx +90 -0
  110. package/src/execution/__tests__/useArtifactDownload.test.tsx +2 -0
  111. package/src/execution/index.ts +6 -0
  112. package/src/execution/use-build-from-plan-hotkey.ts +1 -1
  113. package/src/execution/useArtifactCopy.ts +109 -0
  114. package/src/execution/useArtifactDownload.ts +17 -5
  115. package/src/execution/useCreateAgentExecution.ts +5 -3
  116. package/src/index.ts +6 -0
  117. package/src/internal/file-tree/FileTreeNode.tsx +13 -2
  118. package/src/library/__tests__/detect-plan-artifact.test.ts +21 -0
  119. package/src/library/detect-plan-artifact.ts +32 -10
  120. package/src/library/index.ts +2 -0
  121. package/src/session/PlanEditor.tsx +1 -1
  122. package/src/session/SessionViewer.tsx +50 -18
  123. package/src/session/__tests__/PlanEditor.test.tsx +4 -4
  124. package/src/session/__tests__/PlanStreamingDocument.test.tsx +1 -1
  125. package/src/session/__tests__/SessionViewer-fileReviewDock.test.tsx +57 -0
  126. package/src/session/__tests__/useSessionConversation.test.tsx +44 -0
  127. package/src/session/facets/__tests__/ArtifactsTab.test.tsx +7 -7
  128. package/src/session/plan-document.ts +14 -3
  129. package/src/session/usePlanDraft.ts +1 -1
  130. package/src/session/useSessionConversation.ts +32 -4
  131. package/src/workspace/EditorTabs.tsx +38 -9
  132. package/src/workspace/ExplorerTree.tsx +10 -4
  133. package/src/workspace/FileViewer.tsx +27 -6
  134. package/src/workspace/WorkspaceContentSearch.tsx +44 -20
  135. package/src/workspace/WorkspaceEditor.tsx +19 -16
  136. package/src/workspace/WorkspaceFileSearch.tsx +51 -28
  137. package/src/workspace/WorkspaceSurface.tsx +106 -48
  138. package/src/workspace/__tests__/EditorTabs.test.tsx +4 -2
  139. package/src/workspace/__tests__/ExplorerTree.test.tsx +52 -0
  140. package/src/workspace/__tests__/FileViewer.test.tsx +18 -0
  141. package/src/workspace/__tests__/WorkspaceContentSearch.test.tsx +38 -5
  142. package/src/workspace/__tests__/WorkspaceEditor-initialPanel.test.tsx +19 -0
  143. package/src/workspace/__tests__/WorkspaceFileSearch.test.tsx +27 -6
  144. package/src/workspace/__tests__/WorkspaceSurface.test.tsx +70 -0
  145. package/src/workspace/__tests__/a11y/harness.tsx +140 -0
  146. package/src/workspace/__tests__/a11y/mock-capabilities.ts +138 -0
  147. package/src/workspace/__tests__/a11y/workspace-editor.a11y.test.tsx +65 -0
  148. package/src/workspace/__tests__/a11y/workspace-explorer-search.a11y.test.tsx +112 -0
  149. package/src/workspace/__tests__/a11y/workspace-viewer-tabs.a11y.test.tsx +69 -0
  150. package/styles.css +1 -1
  151. package/workspace/EditorTabs.d.ts +21 -3
  152. package/workspace/EditorTabs.d.ts.map +1 -1
  153. package/workspace/EditorTabs.js +18 -6
  154. package/workspace/EditorTabs.js.map +1 -1
  155. package/workspace/ExplorerTree.js +8 -4
  156. package/workspace/ExplorerTree.js.map +1 -1
  157. package/workspace/FileViewer.d.ts.map +1 -1
  158. package/workspace/FileViewer.js +19 -8
  159. package/workspace/FileViewer.js.map +1 -1
  160. package/workspace/WorkspaceContentSearch.d.ts.map +1 -1
  161. package/workspace/WorkspaceContentSearch.js +23 -2
  162. package/workspace/WorkspaceContentSearch.js.map +1 -1
  163. package/workspace/WorkspaceEditor.d.ts.map +1 -1
  164. package/workspace/WorkspaceEditor.js +8 -10
  165. package/workspace/WorkspaceEditor.js.map +1 -1
  166. package/workspace/WorkspaceFileSearch.d.ts.map +1 -1
  167. package/workspace/WorkspaceFileSearch.js +23 -3
  168. package/workspace/WorkspaceFileSearch.js.map +1 -1
  169. package/workspace/WorkspaceSurface.d.ts.map +1 -1
  170. package/workspace/WorkspaceSurface.js +54 -18
  171. package/workspace/WorkspaceSurface.js.map +1 -1
@@ -0,0 +1,125 @@
1
+ /**
2
+ * FileChangeProgressBar — the live "N files changed so far" strip for a running
3
+ * turn (DD-32). These tests cover its own responsibilities: emptiness, the
4
+ * summary count + aggregate stat, the expandable per-file list with its cap
5
+ * overflow, zero-count hiding, and that only the count is an aria-live region.
6
+ */
7
+
8
+ import { describe, it, expect, afterEach } from "vitest";
9
+ import { render, screen, fireEvent, cleanup } from "@testing-library/react";
10
+ import { create } from "@bufbuild/protobuf";
11
+ import {
12
+ FileChangeProgressSchema,
13
+ FileChangeProgressEntrySchema,
14
+ type FileChangeProgress,
15
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
16
+ import { FileChangeKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
17
+ import { FileChangeProgressBar } from "../FileChangeProgressBar";
18
+
19
+ function entry(path: string, kind: FileChangeKind, added: number, removed: number) {
20
+ return create(FileChangeProgressEntrySchema, {
21
+ pathBefore: kind === FileChangeKind.ADD ? "" : path,
22
+ pathAfter: kind === FileChangeKind.DELETE ? "" : path,
23
+ kind,
24
+ linesAdded: added,
25
+ linesRemoved: removed,
26
+ });
27
+ }
28
+
29
+ function progress(overrides: Partial<FileChangeProgress> = {}): FileChangeProgress {
30
+ return create(FileChangeProgressSchema, {
31
+ changeSetId: "exec:0",
32
+ filesChanged: 2,
33
+ linesAdded: 7,
34
+ linesRemoved: 3,
35
+ entries: [
36
+ entry("src/a.ts", FileChangeKind.ADD, 5, 0),
37
+ entry("src/b.ts", FileChangeKind.MODIFY, 2, 3),
38
+ ],
39
+ capturedAt: "2026-07-05T00:00:00Z",
40
+ ...overrides,
41
+ });
42
+ }
43
+
44
+ afterEach(() => cleanup());
45
+
46
+ describe("FileChangeProgressBar", () => {
47
+ it("renders nothing when progress is undefined", () => {
48
+ const { container } = render(<FileChangeProgressBar progress={undefined} />);
49
+ expect(container.firstChild).toBeNull();
50
+ });
51
+
52
+ it("renders nothing when zero files have changed (revert-to-clean)", () => {
53
+ const { container } = render(
54
+ <FileChangeProgressBar
55
+ progress={progress({ filesChanged: 0, entries: [], linesAdded: 0, linesRemoved: 0 })}
56
+ />,
57
+ );
58
+ expect(container.firstChild).toBeNull();
59
+ });
60
+
61
+ it("shows the summary count and aggregate +N −M", () => {
62
+ render(<FileChangeProgressBar progress={progress()} />);
63
+ expect(screen.getByText(/2 files changing/)).toBeTruthy();
64
+ expect(screen.getByText("+7")).toBeTruthy();
65
+ expect(screen.getByText("-3")).toBeTruthy();
66
+ });
67
+
68
+ it("uses the singular noun for a single file", () => {
69
+ render(
70
+ <FileChangeProgressBar
71
+ progress={progress({ filesChanged: 1, entries: [entry("only.ts", FileChangeKind.ADD, 1, 0)] })}
72
+ />,
73
+ );
74
+ expect(screen.getByText(/1 file changing/)).toBeTruthy();
75
+ });
76
+
77
+ it("puts only the count in an aria-live region, not the per-file list", () => {
78
+ render(<FileChangeProgressBar progress={progress()} />);
79
+ const live = screen.getByText(/2 files changing/);
80
+ expect(live.getAttribute("aria-live")).toBe("polite");
81
+ // The expandable list is not part of any live region.
82
+ fireEvent.click(screen.getByRole("button", { name: /2 files changing/ }));
83
+ const link = screen.getByText("a.ts");
84
+ expect(link.closest("[aria-live]")).toBeNull();
85
+ });
86
+
87
+ it("expands to a per-file list on click and collapses again", () => {
88
+ render(<FileChangeProgressBar progress={progress()} />);
89
+ expect(screen.queryByText("a.ts")).toBeNull();
90
+
91
+ const toggle = screen.getByRole("button", { name: /2 files changing/ });
92
+ fireEvent.click(toggle);
93
+ expect(screen.getByText("a.ts")).toBeTruthy();
94
+ expect(screen.getByText("b.ts")).toBeTruthy();
95
+ expect(toggle.getAttribute("aria-expanded")).toBe("true");
96
+
97
+ fireEvent.click(toggle);
98
+ expect(screen.queryByText("a.ts")).toBeNull();
99
+ });
100
+
101
+ it("shows an honest overflow line when the entry list is capped", () => {
102
+ // 5 files changed but only 2 entries carried → "… and 3 more".
103
+ render(<FileChangeProgressBar progress={progress({ filesChanged: 5 })} />);
104
+ fireEvent.click(screen.getByRole("button", { name: /5 files changing/ }));
105
+ expect(screen.getByText(/and 3 more/)).toBeTruthy();
106
+ });
107
+
108
+ it("hides the per-file stat when both counts are zero (binary/secret)", () => {
109
+ render(
110
+ <FileChangeProgressBar
111
+ progress={progress({
112
+ filesChanged: 1,
113
+ linesAdded: 0,
114
+ linesRemoved: 0,
115
+ entries: [entry(".env", FileChangeKind.MODIFY, 0, 0)],
116
+ })}
117
+ />,
118
+ );
119
+ fireEvent.click(screen.getByRole("button", { name: /1 file changing/ }));
120
+ // The secret path is present…
121
+ expect(screen.getByText(".env")).toBeTruthy();
122
+ // …but no +0 −0 stat is rendered anywhere.
123
+ expect(screen.queryByText("+0")).toBeNull();
124
+ });
125
+ });
@@ -248,6 +248,16 @@ describe("FileReviewCard", () => {
248
248
  });
249
249
 
250
250
  describe("multi-file set (per-file controls)", () => {
251
+ it("starts a complete multi-file set collapsed in diff mode — auto-expansion is list-mode only", () => {
252
+ // Diff mode's expanded body is every full diff with NO height cap (the
253
+ // workflow file-review list); auto-expanding a multi-file set there
254
+ // would dump every diff open on mount. Only the compact list mode
255
+ // (showDiffs=false, the dock) starts multi-file sets expanded.
256
+ render(<FileReviewCard fileChangeSet={multiChangeSet()} onSubmit={noop} />);
257
+ expect(document.querySelector('[data-cursor-target="file-diff"]')).toBeNull();
258
+ expect(screen.getByRole("button", { name: "Review" })).toBeTruthy();
259
+ });
260
+
251
261
  it("renders a Keep/Discard radio per file plus whole-set controls", () => {
252
262
  render(<FileReviewCard fileChangeSet={multiChangeSet()} onSubmit={noop} />);
253
263
  expandCard();
@@ -848,11 +858,12 @@ describe("FileReviewCard", () => {
848
858
  // cases mirror the decision cases above without the diff DOM. The default
849
859
  // (showDiffs unset) stays covered by every other suite in this file.
850
860
  describe("list mode (showDiffs=false)", () => {
861
+ /** Open a still-collapsed card's file inventory (single complete file). */
851
862
  function expandFiles() {
852
863
  fireEvent.click(screen.getByRole("button", { name: "Files" }));
853
864
  }
854
865
 
855
- it("labels the expander 'Files' and renders inert list rows with no diff DOM", () => {
866
+ it("starts a multi-file set expanded the per-file controls are the honest decision surface", () => {
856
867
  render(
857
868
  <FileReviewCard
858
869
  fileChangeSet={multiChangeSet()}
@@ -860,17 +871,65 @@ describe("FileReviewCard", () => {
860
871
  showDiffs={false}
861
872
  />,
862
873
  );
863
- expandFiles();
874
+ // No expander click: the list rows and per-file controls are immediately
875
+ // visible, with no diff DOM (the transcript's stamped rows own the diffs).
864
876
  expect(document.querySelector('[data-cursor-target="file-diff"]')).toBeNull();
865
877
  expect(
866
878
  document.querySelectorAll('[data-cursor-target="file-review-list-row"]'),
867
879
  ).toHaveLength(2);
868
- // Filename-first path text, full path available on hover — and no
869
- // link/copy affordance (the list is an inventory, not navigation).
880
+ expect(screen.getByRole("button", { name: "Hide" })).toBeTruthy();
881
+ // Filename-first linked path (FilePathLink: copy fallback without a
882
+ // provider — an accessible button, never a bare span), full path on hover.
870
883
  expect(screen.getByText("a.ts")).toBeTruthy();
871
884
  expect(screen.getByText("b.ts")).toBeTruthy();
885
+ expect(screen.getByRole("button", { name: /Copy path: src\/a\.ts/ })).toBeTruthy();
872
886
  expect(screen.getByTitle("src/a.ts")).toBeTruthy();
873
- expect(screen.queryByRole("link")).toBeNull();
887
+ });
888
+
889
+ it("keeps a single complete file collapsed behind the 'Files' expander", () => {
890
+ render(
891
+ <FileReviewCard fileChangeSet={changeSet()} onSubmit={noop} showDiffs={false} />,
892
+ );
893
+ expect(
894
+ document.querySelector('[data-cursor-target="file-review-list-row"]'),
895
+ ).toBeNull();
896
+ fireEvent.click(screen.getByRole("button", { name: "Files" }));
897
+ expect(
898
+ document.querySelector('[data-cursor-target="file-review-list-row"]'),
899
+ ).toBeTruthy();
900
+ });
901
+
902
+ it("shows per-file +N −M from the capture-time counts, hiding the stat when no count exists", () => {
903
+ const counted = create(CapturedFileChangeSchema, {
904
+ id: "fc1",
905
+ pathBefore: "src/a.ts",
906
+ pathAfter: "src/a.ts",
907
+ kind: FileChangeKind.MODIFY,
908
+ captureClass: FileCaptureClass.GIT_TRACKED,
909
+ before: inline("old\n"),
910
+ after: inline("new\n"),
911
+ fileDigest: "d-fc1",
912
+ diffComplete: true,
913
+ linesAdded: 37,
914
+ linesRemoved: 2,
915
+ });
916
+ render(
917
+ <FileReviewCard
918
+ fileChangeSet={multiChangeSet({ fc1: counted })}
919
+ onSubmit={noop}
920
+ showDiffs={false}
921
+ />,
922
+ );
923
+ const rows = document.querySelectorAll('[data-cursor-target="file-review-list-row"]');
924
+ // fc1 carries counts; fc2 (legacy fixture, zero counts) shows no stat —
925
+ // never a misleading "+0 -0". The bar also aggregates the set's counts,
926
+ // so exactly two stats render: the bar's and fc1's row.
927
+ const stats = document.querySelectorAll('[data-cursor-target="file-review-line-stats"]');
928
+ expect(rows).toHaveLength(2);
929
+ expect(stats).toHaveLength(2);
930
+ expect(stats[0].textContent).toBe("+37 -2");
931
+ expect(stats[1].textContent).toBe("+37 -2");
932
+ expect(screen.getAllByLabelText("37 added, 2 removed")).toHaveLength(2);
874
933
  });
875
934
 
876
935
  it("marks each file with its change-kind letter (A/M/D)", () => {
@@ -899,7 +958,7 @@ describe("FileReviewCard", () => {
899
958
  showDiffs={false}
900
959
  />,
901
960
  );
902
- expandFiles();
961
+ // A multi-file list-mode set starts expanded — the letters are visible.
903
962
  expect(screen.getByLabelText("added").textContent).toBe("A");
904
963
  expect(screen.getByLabelText("deleted").textContent).toBe("D");
905
964
  });
@@ -937,7 +996,7 @@ describe("FileReviewCard", () => {
937
996
  showDiffs={false}
938
997
  />,
939
998
  );
940
- expandFiles();
999
+ // Starts expanded (multi-file list mode) — the row control is live.
941
1000
  fireEvent.click(screen.getByRole("radio", { name: "Keep src/a.ts" }));
942
1001
  expect(onSubmit).toHaveBeenCalledWith(FileDecisionAction.APPROVE, {
943
1002
  scope: FileDecisionScope.FILE,
@@ -844,7 +844,7 @@ describe("MessageThread", () => {
844
844
  />,
845
845
  );
846
846
 
847
- const buildBtn = screen.getByRole("button", { name: /build from plan/i });
847
+ const buildBtn = screen.getByRole("button", { name: "Build" });
848
848
  expect(buildBtn).toBeTruthy();
849
849
 
850
850
  fireEvent.click(buildBtn);
@@ -85,7 +85,7 @@ describe("PlanArtifactCard — compact document card", () => {
85
85
  expect(getArtifactContent).not.toHaveBeenCalled();
86
86
  });
87
87
 
88
- it("offers exactly one primary 'Build from plan' action that calls onImplement", () => {
88
+ it("offers exactly one primary 'Build' action that calls onImplement", () => {
89
89
  const onImplement = vi.fn();
90
90
  renderCard(
91
91
  <PlanArtifactCard
@@ -96,15 +96,15 @@ describe("PlanArtifactCard — compact document card", () => {
96
96
  />,
97
97
  );
98
98
 
99
- fireEvent.click(screen.getByText("Build from plan"));
99
+ fireEvent.click(screen.getByRole("button", { name: "Build" }));
100
100
  expect(onImplement).toHaveBeenCalledTimes(1);
101
101
  });
102
102
 
103
- it("hides 'Build from plan' when onImplement is not provided", () => {
103
+ it("hides 'Build' when onImplement is not provided", () => {
104
104
  renderCard(
105
105
  <PlanArtifactCard executionId="aex_1" artifact={planArtifact} org="acme" />,
106
106
  );
107
- expect(screen.queryByText("Build from plan")).toBeNull();
107
+ expect(screen.queryByRole("button", { name: "Build" })).toBeNull();
108
108
  });
109
109
 
110
110
  it("shows the pending label while the approved plan uploads (buildPending)", () => {
@@ -121,12 +121,12 @@ describe("PlanArtifactCard — compact document card", () => {
121
121
 
122
122
  // The primary reads as in-progress and stays disabled — no double-submits
123
123
  // while the upload precedes the implement turn.
124
- expect(screen.queryByText("Build from plan")).toBeNull();
124
+ expect(screen.queryByRole("button", { name: "Build" })).toBeNull();
125
125
  const button = screen.getByText("Starting build…").closest("button")!;
126
126
  expect(button.disabled).toBe(true);
127
127
  });
128
128
 
129
- it("disables 'Build from plan' when disabled", () => {
129
+ it("disables 'Build' when disabled", () => {
130
130
  const onImplement = vi.fn();
131
131
  renderCard(
132
132
  <PlanArtifactCard
@@ -138,12 +138,44 @@ describe("PlanArtifactCard — compact document card", () => {
138
138
  />,
139
139
  );
140
140
 
141
- const button = screen.getByText("Build from plan").closest("button")!;
141
+ const button = screen.getByRole("button", { name: "Build" }) as HTMLButtonElement;
142
142
  expect(button.disabled).toBe(true);
143
143
  fireEvent.click(button);
144
144
  expect(onImplement).not.toHaveBeenCalled();
145
145
  });
146
146
 
147
+ it("copies the plan text to the clipboard on demand", async () => {
148
+ const writeText = vi.fn().mockResolvedValue(undefined);
149
+ vi.stubGlobal("navigator", { clipboard: { writeText } });
150
+ const getArtifactContent = vi
151
+ .fn()
152
+ .mockResolvedValue({ content: new TextEncoder().encode("# Plan\n\nsteps") });
153
+ const getArtifactDownloadUrl = vi.fn();
154
+ const stigmer = {
155
+ agentExecution: { getArtifactContent, getArtifactDownloadUrl },
156
+ } as unknown as Stigmer;
157
+
158
+ render(
159
+ withStigmer(
160
+ <PlanArtifactCard executionId="aex_1" artifact={planArtifact} org="acme" />,
161
+ stigmer,
162
+ ),
163
+ );
164
+
165
+ fireEvent.click(screen.getByRole("button", { name: "Copy plan" }));
166
+
167
+ // Content is fetched at click time from the stable storage key, then
168
+ // written to the clipboard — the card never pre-fetches on mount.
169
+ expect(getArtifactContent).toHaveBeenCalledTimes(1);
170
+ const req = getArtifactContent.mock.calls[0][0];
171
+ expect(req.executionId).toBe("aex_1");
172
+ expect(req.storageKey).toBe("artifacts/aex_1/plan.md");
173
+ await screen.findByRole("button", { name: "Copied" });
174
+ expect(writeText).toHaveBeenCalledWith("# Plan\n\nsteps");
175
+
176
+ vi.unstubAllGlobals();
177
+ });
178
+
147
179
  it("downloads the artifact on demand via a freshly minted URL", async () => {
148
180
  const { stigmer, getArtifactDownloadUrl } = createStigmerMock();
149
181
  render(
@@ -152,7 +184,7 @@ describe("PlanArtifactCard — compact document card", () => {
152
184
  stigmer,
153
185
  ),
154
186
  );
155
- fireEvent.click(screen.getByText("Download"));
187
+ fireEvent.click(screen.getByRole("button", { name: "Download plan.md" }));
156
188
  // The URL is resolved at click time from the stable storage key — never a
157
189
  // baked, expirable URL.
158
190
  expect(getArtifactDownloadUrl).toHaveBeenCalledTimes(1);
@@ -229,8 +261,8 @@ describe("PlanArtifactCard — Cmd/Ctrl+Enter accelerator (card-scoped)", () =>
229
261
  });
230
262
  });
231
263
 
232
- describe("PlanArtifactCard — Open full (org-gated preview)", () => {
233
- it("opens the shared preview modal when 'Open full' is clicked", () => {
264
+ describe("PlanArtifactCard — Open plan modal fallback (org-gated preview)", () => {
265
+ it("opens the shared preview modal when the open icon is clicked (no onOpenPlan)", () => {
234
266
  const { stigmer } = createStigmerMock();
235
267
  render(
236
268
  withStigmer(
@@ -242,14 +274,14 @@ describe("PlanArtifactCard — Open full (org-gated preview)", () => {
242
274
  // No dialog before the user opens it.
243
275
  expect(document.querySelector("dialog")).toBeNull();
244
276
 
245
- fireEvent.click(screen.getByText("Open full"));
277
+ fireEvent.click(screen.getByRole("button", { name: "Open plan" }));
246
278
 
247
279
  const dialog = document.querySelector("dialog");
248
280
  expect(dialog).toBeTruthy();
249
281
  expect(dialog!.getAttribute("aria-label")).toBe("Preview plan.md");
250
282
  });
251
283
 
252
- it("hides 'Open full' when org is absent (modal needs org), keeping Build + Download", () => {
284
+ it("hides the open action when org is absent (modal needs org), keeping Build + Download", () => {
253
285
  const onImplement = vi.fn();
254
286
  renderCard(
255
287
  <PlanArtifactCard
@@ -258,14 +290,16 @@ describe("PlanArtifactCard — Open full (org-gated preview)", () => {
258
290
  onImplement={onImplement}
259
291
  />,
260
292
  );
261
- expect(screen.queryByText("Open full")).toBeNull();
262
- expect(screen.queryByText("Build from plan")).not.toBeNull();
263
- expect(screen.queryByText("Download")).not.toBeNull();
293
+ expect(screen.queryByRole("button", { name: "Open plan" })).toBeNull();
294
+ expect(screen.queryByRole("button", { name: "Build" })).not.toBeNull();
295
+ expect(
296
+ screen.queryByRole("button", { name: "Download plan.md" }),
297
+ ).not.toBeNull();
264
298
  });
265
299
  });
266
300
 
267
301
  describe("PlanArtifactCard — Open plan (panel-first review)", () => {
268
- it("replaces the modal secondary with 'Open plan' when onOpenPlan is provided", () => {
302
+ it("routes the open action to the panel when onOpenPlan is provided (no modal)", () => {
269
303
  const onOpenPlan = vi.fn();
270
304
  renderCard(
271
305
  <PlanArtifactCard
@@ -277,9 +311,9 @@ describe("PlanArtifactCard — Open plan (panel-first review)", () => {
277
311
  );
278
312
 
279
313
  // One review affordance, not two: the facet supersedes the modal.
280
- expect(screen.queryByText("Open full")).toBeNull();
314
+ expect(screen.getAllByRole("button", { name: "Open plan" })).toHaveLength(1);
281
315
 
282
- fireEvent.click(screen.getByText("Open plan"));
316
+ fireEvent.click(screen.getByRole("button", { name: "Open plan" }));
283
317
  expect(onOpenPlan).toHaveBeenCalledTimes(1);
284
318
  expect(document.querySelector("dialog")).toBeNull();
285
319
  });
@@ -3,7 +3,7 @@ import { render, fireEvent, cleanup } from "@testing-library/react";
3
3
  import { PlanCompletionCard } from "../PlanCompletionCard";
4
4
 
5
5
  describe("PlanCompletionCard", () => {
6
- it("renders the card with status text and a 'Build from plan' button", () => {
6
+ it("renders the card with status text and a 'Build' button", () => {
7
7
  const { container } = render(<PlanCompletionCard onImplement={() => {}} />);
8
8
 
9
9
  const root = container.firstElementChild as HTMLElement;
@@ -13,7 +13,7 @@ describe("PlanCompletionCard", () => {
13
13
 
14
14
  const button = root.querySelector("button");
15
15
  expect(button).toBeTruthy();
16
- expect(button!.textContent).toContain("Build from plan");
16
+ expect(button!.textContent).toContain("Build");
17
17
 
18
18
  expect(root.textContent).toContain("Plan complete");
19
19
  cleanup();
@@ -5,7 +5,7 @@ import { PlanStreamingCard } from "../PlanStreamingCard";
5
5
  afterEach(cleanup);
6
6
 
7
7
  describe("PlanStreamingCard — live plan stand-in", () => {
8
- it("is a busy region with the streaming title, filename, and live size", () => {
8
+ it("is a busy region with the streaming title, status, and live size", () => {
9
9
  render(
10
10
  <PlanStreamingCard
11
11
  title="Refactor the auth flow"
@@ -17,11 +17,18 @@ describe("PlanStreamingCard — live plan stand-in", () => {
17
17
  const region = screen.getByRole("region", { name: "Plan being written" });
18
18
  expect(region.getAttribute("aria-busy")).toBe("true");
19
19
  expect(region.textContent).toContain("Refactor the auth flow");
20
- expect(region.textContent).toContain("plan.md");
21
20
  expect(region.textContent).toContain("Writing…");
22
21
  expect(region.textContent).toContain("2.0 KB");
23
22
  });
24
23
 
24
+ it("shows no filename mid-stream (the final name is not known yet)", () => {
25
+ render(
26
+ <PlanStreamingCard title="Refactor the auth flow" sizeBytes={2048} onOpenPlan={vi.fn()} />,
27
+ );
28
+ const region = screen.getByRole("region", { name: "Plan being written" });
29
+ expect(region.textContent).not.toContain("plan.md");
30
+ });
31
+
25
32
  it("falls back to 'Writing plan…' until the title has streamed in", () => {
26
33
  render(<PlanStreamingCard sizeBytes={12} onOpenPlan={vi.fn()} />);
27
34
  expect(screen.getByText("Writing plan…")).toBeTruthy();
@@ -37,8 +44,8 @@ describe("PlanStreamingCard — live plan stand-in", () => {
37
44
 
38
45
  it("offers no Download or Build actions — nothing final exists yet", () => {
39
46
  render(<PlanStreamingCard sizeBytes={64} onOpenPlan={vi.fn()} />);
40
- expect(screen.queryByText("Download")).toBeNull();
41
- expect(screen.queryByText("Build from plan")).toBeNull();
47
+ expect(screen.queryByRole("button", { name: /download/i })).toBeNull();
48
+ expect(screen.queryByText("Build")).toBeNull();
42
49
  });
43
50
 
44
51
  it("renders without an action when onOpenPlan is absent", () => {
@@ -638,7 +638,7 @@ describe("buildThreadItems plan-writing (live streaming collapse)", () => {
638
638
  });
639
639
  });
640
640
 
641
- describe("buildThreadItems build-from-plan prompt", () => {
641
+ describe("buildThreadItems build-from-plan prompt suppression", () => {
642
642
  function promptItem(items: readonly ThreadItem[], execId: string) {
643
643
  return items.find(
644
644
  (i): i is Extract<ThreadItem, { kind: "message" }> =>
@@ -646,7 +646,7 @@ describe("buildThreadItems build-from-plan prompt", () => {
646
646
  );
647
647
  }
648
648
 
649
- it("stamps isBuildFromPlan on the prompt of a build turn", () => {
649
+ it("synthesizes NO prompt item for a build turn (the label is machine-written, not user prose)", () => {
650
650
  const exec = makeExecution({
651
651
  id: "exec-build",
652
652
  specMessage: "Build from plan",
@@ -656,14 +656,17 @@ describe("buildThreadItems build-from-plan prompt", () => {
656
656
  });
657
657
 
658
658
  const items = buildThreadItems([exec], null, null, false, undefined);
659
- const prompt = promptItem(items, "exec-build");
660
659
 
661
- expect(prompt).toBeDefined();
662
- expect(prompt!.isBuildFromPlan).toBe(true);
663
- expect(prompt!.message.content).toBe("Build from plan");
660
+ expect(promptItem(items, "exec-build")).toBeUndefined();
661
+ // The turn's agent output still renders — only the fake prompt is gone.
662
+ expect(
663
+ items.some(
664
+ (i) => i.kind === "message" && i.message.content === "Implementing…",
665
+ ),
666
+ ).toBe(true);
664
667
  });
665
668
 
666
- it("leaves the flag unset on an ordinary prompt (renders as a normal bubble)", () => {
669
+ it("still synthesizes the prompt for an ordinary turn", () => {
667
670
  const exec = makeExecution({
668
671
  id: "exec-normal",
669
672
  interactionMode: InteractionMode.AGENT,
@@ -674,6 +677,99 @@ describe("buildThreadItems build-from-plan prompt", () => {
674
677
  const prompt = promptItem(items, "exec-normal");
675
678
 
676
679
  expect(prompt).toBeDefined();
677
- expect(prompt!.isBuildFromPlan).toBeUndefined();
680
+ expect(prompt!.message.content).toBe("test message");
681
+ });
682
+
683
+ it("offers no retryMessage on a failed build turn's error item (a plain resend would drop the flag)", () => {
684
+ const exec = makeExecution({
685
+ id: "exec-build-fail",
686
+ specMessage: "Build from plan",
687
+ phase: ExecutionPhase.EXECUTION_FAILED,
688
+ interactionMode: InteractionMode.AGENT,
689
+ buildFromPlan: true,
690
+ });
691
+ exec.status!.error = "runner crashed";
692
+
693
+ const items = buildThreadItems([exec], null, null, false, undefined);
694
+ const errorItem = items.find(
695
+ (i): i is Extract<ThreadItem, { kind: "execution-error" }> =>
696
+ i.kind === "execution-error",
697
+ );
698
+
699
+ expect(errorItem).toBeDefined();
700
+ expect(errorItem!.retryMessage).toBeUndefined();
701
+ });
702
+
703
+ it("keeps retryMessage on an ordinary failed turn", () => {
704
+ const exec = makeExecution({
705
+ id: "exec-fail",
706
+ specMessage: "do the thing",
707
+ phase: ExecutionPhase.EXECUTION_FAILED,
708
+ interactionMode: InteractionMode.AGENT,
709
+ });
710
+ exec.status!.error = "runner crashed";
711
+
712
+ const items = buildThreadItems([exec], null, null, false, undefined);
713
+ const errorItem = items.find(
714
+ (i): i is Extract<ThreadItem, { kind: "execution-error" }> =>
715
+ i.kind === "execution-error",
716
+ );
717
+
718
+ expect(errorItem!.retryMessage).toBe("do the thing");
719
+ });
720
+ });
721
+
722
+ describe("buildThreadItems pending-bubble tail guard", () => {
723
+ function pendingItem(items: readonly ThreadItem[]) {
724
+ return items.find(
725
+ (i): i is Extract<ThreadItem, { kind: "message" }> =>
726
+ i.kind === "message" && i.key === "pending-user-turn",
727
+ );
728
+ }
729
+
730
+ it("suppresses the pending bubble once the same turn has streamed in (succeeded send)", () => {
731
+ const exec = makeExecution({
732
+ id: "exec-1",
733
+ specMessage: "hello there",
734
+ interactionMode: InteractionMode.AGENT,
735
+ });
736
+
737
+ const items = buildThreadItems(
738
+ [exec],
739
+ null,
740
+ "hello there",
741
+ false,
742
+ undefined,
743
+ );
744
+
745
+ expect(pendingItem(items)).toBeUndefined();
746
+ });
747
+
748
+ it("renders a FAILED pending message even when an older execution carries identical text", () => {
749
+ // Two build attempts of the same plan share the message "Build from
750
+ // plan". A failed second send created no execution, so the first build
751
+ // turn must never swallow the failed bubble — the failure would
752
+ // otherwise render nothing at all (build turns synthesize no prompt).
753
+ const firstBuild = makeExecution({
754
+ id: "exec-build-1",
755
+ specMessage: "Build from plan",
756
+ interactionMode: InteractionMode.AGENT,
757
+ buildFromPlan: true,
758
+ });
759
+
760
+ const items = buildThreadItems(
761
+ [firstBuild],
762
+ null,
763
+ "Build from plan",
764
+ false,
765
+ undefined,
766
+ undefined,
767
+ true, // pendingMessageFailed
768
+ );
769
+
770
+ const failed = pendingItem(items);
771
+ expect(failed).toBeDefined();
772
+ expect(failed!.isFailed).toBe(true);
773
+ expect(failed!.message.content).toBe("Build from plan");
678
774
  });
679
775
  });
@@ -195,51 +195,20 @@ describe("MessageEntry — Human messages", () => {
195
195
  });
196
196
 
197
197
  // ---------------------------------------------------------------------------
198
- // Build-from-plan chipthe flagged human turn renders as a compact pill
198
+ // Build-from-plan turnsno special human-message treatment
199
199
  // ---------------------------------------------------------------------------
200
200
 
201
- describe("MessageEntry — Build-from-plan chip", () => {
202
- it("renders a compact chip instead of a prompt bubble when flagged", () => {
203
- const msg = makeMessage(MessageType.MESSAGE_HUMAN, "Build from plan");
204
- const { container } = render(
205
- <MessageEntry message={msg} isBuildFromPlan />,
206
- );
207
-
208
- const chip = queryArticle(container, "Build from plan");
209
- expect(chip).not.toBeNull();
210
- expect(chip!.textContent).toContain("Build from plan");
211
- // The ordinary prompt bubble must not render alongside the chip.
212
- expect(queryArticle(container, "User message")).toBeNull();
213
- });
214
-
215
- it("keeps the ordinary bubble when the flag is absent", () => {
201
+ describe("MessageEntry — Build-from-plan turns", () => {
202
+ it("renders any human message as the ordinary prompt bubble (the thread hides build turns before they reach MessageEntry)", () => {
203
+ // The retired chip treatment lived here; today buildThreadItems skips
204
+ // synthesizing the prompt item for a build turn entirely, so a human
205
+ // message that DOES reach MessageEntry is always ordinary prose.
216
206
  const msg = makeMessage(MessageType.MESSAGE_HUMAN, "Build from plan");
217
207
  const { container } = render(<MessageEntry message={msg} />);
218
208
 
219
209
  expect(queryArticle(container, "User message")).not.toBeNull();
220
210
  expect(queryArticle(container, "Build from plan")).toBeNull();
221
211
  });
222
-
223
- it("never offers Edit on the chip (the label is not user prose)", () => {
224
- const msg = makeMessage(MessageType.MESSAGE_HUMAN, "Build from plan");
225
- const { container } = render(
226
- <MessageEntry message={msg} isBuildFromPlan onEdit={() => {}} />,
227
- );
228
-
229
- expect(
230
- container.querySelector('[aria-label="Edit message"]'),
231
- ).toBeNull();
232
- });
233
-
234
- it("ignores the flag on non-human messages", () => {
235
- const msg = makeMessage(MessageType.MESSAGE_AI, "Implementing…");
236
- const { container } = render(
237
- <MessageEntry message={msg} isBuildFromPlan />,
238
- );
239
-
240
- expect(queryArticle(container, "AI response")).not.toBeNull();
241
- expect(queryArticle(container, "Build from plan")).toBeNull();
242
- });
243
212
  });
244
213
 
245
214
  // ---------------------------------------------------------------------------