@stigmer/react 3.12.6 → 3.12.8

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 (219) hide show
  1. package/composer/ComposerToolbar.d.ts.map +1 -1
  2. package/composer/ComposerToolbar.js.map +1 -1
  3. package/execution/MessageThread.d.ts.map +1 -1
  4. package/execution/MessageThread.js +8 -11
  5. package/execution/MessageThread.js.map +1 -1
  6. package/execution/ResultView.d.ts.map +1 -1
  7. package/execution/ResultView.js +6 -0
  8. package/execution/ResultView.js.map +1 -1
  9. package/execution/ToolCallItem.d.ts.map +1 -1
  10. package/execution/ToolCallItem.js +13 -2
  11. package/execution/ToolCallItem.js.map +1 -1
  12. package/execution/tool-categories.d.ts +1 -1
  13. package/execution/tool-categories.d.ts.map +1 -1
  14. package/execution/tool-categories.js +6 -0
  15. package/execution/tool-categories.js.map +1 -1
  16. package/execution/useFileChangeContent.d.ts +2 -6
  17. package/execution/useFileChangeContent.d.ts.map +1 -1
  18. package/execution/useFileChangeContent.js +4 -12
  19. package/execution/useFileChangeContent.js.map +1 -1
  20. package/identity-account/AccountPreferencesPanel.d.ts +2 -1
  21. package/identity-account/AccountPreferencesPanel.d.ts.map +1 -1
  22. package/identity-account/AccountPreferencesPanel.js +41 -3
  23. package/identity-account/AccountPreferencesPanel.js.map +1 -1
  24. package/identity-account/useAccountExecutionDefaults.d.ts +7 -0
  25. package/identity-account/useAccountExecutionDefaults.d.ts.map +1 -1
  26. package/identity-account/useAccountExecutionDefaults.js +5 -2
  27. package/identity-account/useAccountExecutionDefaults.js.map +1 -1
  28. package/index.d.ts +5 -2
  29. package/index.d.ts.map +1 -1
  30. package/index.js +5 -1
  31. package/index.js.map +1 -1
  32. package/internal/MemoryEnabledRow.d.ts +37 -0
  33. package/internal/MemoryEnabledRow.d.ts.map +1 -0
  34. package/internal/MemoryEnabledRow.js +25 -0
  35. package/internal/MemoryEnabledRow.js.map +1 -0
  36. package/internal/download.d.ts +8 -0
  37. package/internal/download.d.ts.map +1 -0
  38. package/internal/download.js +25 -0
  39. package/internal/download.js.map +1 -0
  40. package/library/useExportResource.d.ts.map +1 -1
  41. package/library/useExportResource.js +3 -17
  42. package/library/useExportResource.js.map +1 -1
  43. package/memory/MemoryListPanel.d.ts +35 -0
  44. package/memory/MemoryListPanel.d.ts.map +1 -0
  45. package/memory/MemoryListPanel.js +141 -0
  46. package/memory/MemoryListPanel.js.map +1 -0
  47. package/memory/MemoryProposalCard.d.ts +35 -0
  48. package/memory/MemoryProposalCard.d.ts.map +1 -0
  49. package/memory/MemoryProposalCard.js +73 -0
  50. package/memory/MemoryProposalCard.js.map +1 -0
  51. package/memory/index.d.ts +10 -0
  52. package/memory/index.d.ts.map +1 -0
  53. package/memory/index.js +14 -0
  54. package/memory/index.js.map +1 -0
  55. package/memory/memoryGroups.d.ts +31 -0
  56. package/memory/memoryGroups.d.ts.map +1 -0
  57. package/memory/memoryGroups.js +52 -0
  58. package/memory/memoryGroups.js.map +1 -0
  59. package/memory/useConfirmMemory.d.ts +31 -0
  60. package/memory/useConfirmMemory.d.ts.map +1 -0
  61. package/memory/useConfirmMemory.js +43 -0
  62. package/memory/useConfirmMemory.js.map +1 -0
  63. package/memory/useDeleteMemory.d.ts +30 -0
  64. package/memory/useDeleteMemory.d.ts.map +1 -0
  65. package/memory/useDeleteMemory.js +42 -0
  66. package/memory/useDeleteMemory.js.map +1 -0
  67. package/memory/useMemories.d.ts +33 -0
  68. package/memory/useMemories.d.ts.map +1 -0
  69. package/memory/useMemories.js +42 -0
  70. package/memory/useMemories.js.map +1 -0
  71. package/memory/useMemory.d.ts +39 -0
  72. package/memory/useMemory.d.ts.map +1 -0
  73. package/memory/useMemory.js +43 -0
  74. package/memory/useMemory.js.map +1 -0
  75. package/memory/useRejectMemory.d.ts +34 -0
  76. package/memory/useRejectMemory.d.ts.map +1 -0
  77. package/memory/useRejectMemory.js +46 -0
  78. package/memory/useRejectMemory.js.map +1 -0
  79. package/memory/useUpdateMemoryContent.d.ts +32 -0
  80. package/memory/useUpdateMemoryContent.d.ts.map +1 -0
  81. package/memory/useUpdateMemoryContent.js +48 -0
  82. package/memory/useUpdateMemoryContent.js.map +1 -0
  83. package/organization/OrgPreferencesPanel.d.ts.map +1 -1
  84. package/organization/OrgPreferencesPanel.js +36 -4
  85. package/organization/OrgPreferencesPanel.js.map +1 -1
  86. package/package.json +4 -4
  87. package/session/AutoApproveIndicator.d.ts +18 -0
  88. package/session/AutoApproveIndicator.d.ts.map +1 -0
  89. package/session/AutoApproveIndicator.js +23 -0
  90. package/session/AutoApproveIndicator.js.map +1 -0
  91. package/session/NewSessionViewer.d.ts.map +1 -1
  92. package/session/NewSessionViewer.js +10 -1
  93. package/session/NewSessionViewer.js.map +1 -1
  94. package/session/SessionViewer.d.ts +26 -1
  95. package/session/SessionViewer.d.ts.map +1 -1
  96. package/session/SessionViewer.js +28 -22
  97. package/session/SessionViewer.js.map +1 -1
  98. package/session/TranscriptExportMenu.d.ts +24 -0
  99. package/session/TranscriptExportMenu.d.ts.map +1 -0
  100. package/session/TranscriptExportMenu.js +26 -0
  101. package/session/TranscriptExportMenu.js.map +1 -0
  102. package/session/facets/SetupTab.d.ts +43 -6
  103. package/session/facets/SetupTab.d.ts.map +1 -1
  104. package/session/facets/SetupTab.js +32 -36
  105. package/session/facets/SetupTab.js.map +1 -1
  106. package/session/facets/UsageTab.d.ts +2 -1
  107. package/session/facets/UsageTab.d.ts.map +1 -1
  108. package/session/facets/UsageTab.js +11 -5
  109. package/session/facets/UsageTab.js.map +1 -1
  110. package/session/facets/index.d.ts +1 -1
  111. package/session/facets/index.d.ts.map +1 -1
  112. package/session/facets/primitives.d.ts +58 -0
  113. package/session/facets/primitives.d.ts.map +1 -0
  114. package/session/facets/primitives.js +59 -0
  115. package/session/facets/primitives.js.map +1 -0
  116. package/session/index.d.ts +5 -1
  117. package/session/index.d.ts.map +1 -1
  118. package/session/index.js +2 -0
  119. package/session/index.js.map +1 -1
  120. package/session/useExportTranscript.d.ts +44 -0
  121. package/session/useExportTranscript.d.ts.map +1 -0
  122. package/session/useExportTranscript.js +109 -0
  123. package/session/useExportTranscript.js.map +1 -0
  124. package/session/useNewSessionFlow.d.ts +16 -0
  125. package/session/useNewSessionFlow.d.ts.map +1 -1
  126. package/session/useNewSessionFlow.js +25 -6
  127. package/session/useNewSessionFlow.js.map +1 -1
  128. package/session/useSessionConversation.d.ts.map +1 -1
  129. package/session/useSessionConversation.js +7 -19
  130. package/session/useSessionConversation.js.map +1 -1
  131. package/session/useSessionExecutions.d.ts +2 -14
  132. package/session/useSessionExecutions.d.ts.map +1 -1
  133. package/session/useSessionExecutions.js +4 -26
  134. package/session/useSessionExecutions.js.map +1 -1
  135. package/session/useSessionPageFlow.d.ts +41 -10
  136. package/session/useSessionPageFlow.d.ts.map +1 -1
  137. package/session/useSessionPageFlow.js +67 -9
  138. package/session/useSessionPageFlow.js.map +1 -1
  139. package/settings/MemorySection.d.ts +12 -0
  140. package/settings/MemorySection.d.ts.map +1 -0
  141. package/settings/MemorySection.js +21 -0
  142. package/settings/MemorySection.js.map +1 -0
  143. package/settings/index.d.ts +1 -0
  144. package/settings/index.d.ts.map +1 -1
  145. package/settings/index.js +1 -0
  146. package/settings/index.js.map +1 -1
  147. package/settings/settings-nav.d.ts.map +1 -1
  148. package/settings/settings-nav.js +2 -1
  149. package/settings/settings-nav.js.map +1 -1
  150. package/src/__tests__/public-api.test.ts +21 -0
  151. package/src/attachment/__tests__/prepare-image.test.ts +12 -6
  152. package/src/composer/ComposerToolbar.tsx +1 -0
  153. package/src/execution/MessageThread.tsx +8 -11
  154. package/src/execution/ResultView.tsx +6 -0
  155. package/src/execution/ToolCallItem.tsx +18 -1
  156. package/src/execution/tool-categories.ts +7 -0
  157. package/src/execution/useFileChangeContent.ts +5 -12
  158. package/src/identity-account/AccountPreferencesPanel.tsx +89 -2
  159. package/src/identity-account/__tests__/AccountPreferencesPanel.test.tsx +112 -0
  160. package/src/identity-account/__tests__/useAccountExecutionDefaults.test.tsx +34 -1
  161. package/src/identity-account/useAccountExecutionDefaults.ts +12 -2
  162. package/src/index.ts +38 -0
  163. package/src/internal/MemoryEnabledRow.tsx +92 -0
  164. package/src/internal/download.ts +29 -0
  165. package/src/library/useExportResource.ts +3 -19
  166. package/src/memory/MemoryListPanel.tsx +532 -0
  167. package/src/memory/MemoryProposalCard.tsx +181 -0
  168. package/src/memory/__tests__/MemoryListPanel.test.tsx +259 -0
  169. package/src/memory/__tests__/MemoryProposalCard.test.tsx +236 -0
  170. package/src/memory/__tests__/memoryHooks.test.tsx +283 -0
  171. package/src/memory/index.ts +24 -0
  172. package/src/memory/memoryGroups.ts +64 -0
  173. package/src/memory/useConfirmMemory.ts +62 -0
  174. package/src/memory/useDeleteMemory.ts +61 -0
  175. package/src/memory/useMemories.ts +66 -0
  176. package/src/memory/useMemory.ts +80 -0
  177. package/src/memory/useRejectMemory.ts +65 -0
  178. package/src/memory/useUpdateMemoryContent.ts +67 -0
  179. package/src/organization/OrgPreferencesPanel.tsx +52 -3
  180. package/src/organization/__tests__/OrgPreferencesPanel.test.tsx +65 -0
  181. package/src/session/AutoApproveIndicator.tsx +45 -0
  182. package/src/session/NewSessionViewer.tsx +17 -0
  183. package/src/session/SessionViewer.tsx +78 -41
  184. package/src/session/TranscriptExportMenu.tsx +78 -0
  185. package/src/session/__tests__/NewSessionViewer.test.tsx +32 -0
  186. package/src/session/__tests__/SessionViewer-autoApprove.test.tsx +244 -0
  187. package/src/session/__tests__/SessionViewer-transcriptExport.test.tsx +189 -0
  188. package/src/session/__tests__/TranscriptExportMenu.test.tsx +97 -0
  189. package/src/session/__tests__/useExportTranscript.test.tsx +187 -0
  190. package/src/session/__tests__/useNewSessionFlow.test.tsx +89 -0
  191. package/src/session/__tests__/useSessionPageFlow.autoApprove.test.tsx +263 -2
  192. package/src/session/facets/SetupTab.tsx +193 -157
  193. package/src/session/facets/UsageTab.tsx +38 -38
  194. package/src/session/facets/index.ts +1 -0
  195. package/src/session/facets/primitives.tsx +169 -0
  196. package/src/session/index.ts +10 -0
  197. package/src/session/useExportTranscript.ts +171 -0
  198. package/src/session/useNewSessionFlow.ts +42 -6
  199. package/src/session/useSessionConversation.ts +10 -16
  200. package/src/session/useSessionExecutions.ts +5 -26
  201. package/src/session/useSessionPageFlow.ts +108 -21
  202. package/src/settings/MemorySection.tsx +45 -0
  203. package/src/settings/index.ts +1 -0
  204. package/src/settings/settings-nav.ts +2 -0
  205. package/src/switch/Switch.tsx +4 -0
  206. package/src/usage/useExportCSV.ts +2 -14
  207. package/src/workflow/__tests__/useWorkflowCanvas.dirty.test.tsx +7 -2
  208. package/src/workflow/__tests__/useWorkflowCanvas.drag.test.tsx +7 -2
  209. package/src/workspace/WorkspaceSurface.tsx +26 -3
  210. package/styles.css +1 -1
  211. package/switch/Switch.d.ts +3 -1
  212. package/switch/Switch.d.ts.map +1 -1
  213. package/switch/Switch.js +2 -2
  214. package/switch/Switch.js.map +1 -1
  215. package/usage/useExportCSV.d.ts.map +1 -1
  216. package/usage/useExportCSV.js +2 -13
  217. package/usage/useExportCSV.js.map +1 -1
  218. package/workspace/WorkspaceSurface.js +11 -2
  219. package/workspace/WorkspaceSurface.js.map +1 -1
@@ -0,0 +1,78 @@
1
+ "use client";
2
+
3
+ import { Copy, Download, FileJson, FileText } from "lucide-react";
4
+ import { cn } from "@stigmer/theme";
5
+ import { ActionMenu } from "../action-menu/ActionMenu.js";
6
+ import { SpinnerIcon } from "../internal/SpinnerIcon.js";
7
+ import {
8
+ useExportTranscript,
9
+ type UseExportTranscriptOptions,
10
+ } from "./useExportTranscript.js";
11
+
12
+ /** Props for {@link TranscriptExportMenu}. */
13
+ export interface TranscriptExportMenuProps extends UseExportTranscriptOptions {
14
+ /** The session whose conversation is exported. */
15
+ readonly sessionId: string | null;
16
+ /** Additional CSS classes for the trigger button. */
17
+ readonly className?: string;
18
+ }
19
+
20
+ /**
21
+ * The whole-conversation export control (stigmer/stigmer#814): a quiet
22
+ * icon-button menu offering **Copy transcript**, **Download Markdown**, and
23
+ * **Download JSON**, powered by {@link useExportTranscript} — the canonical
24
+ * transcript assembly, thinking and resolved tool outputs included.
25
+ *
26
+ * `SessionViewer` renders it in its header corner by default; it is exported
27
+ * so hosts can mount the same control on any other surface a conversation is
28
+ * reachable from (a session list's row menu, a custom header).
29
+ *
30
+ * The export serializes exactly what the viewer already shows the current
31
+ * caller — the RPCs behind it enforce the same `can_view` permission — so
32
+ * offering it to every audience adds convenience, not exposure.
33
+ */
34
+ export function TranscriptExportMenu({
35
+ sessionId,
36
+ includeSuperseded,
37
+ className,
38
+ }: TranscriptExportMenuProps) {
39
+ const exporter = useExportTranscript(sessionId, { includeSuperseded });
40
+
41
+ return (
42
+ <ActionMenu>
43
+ <ActionMenu.Trigger
44
+ aria-label="Export transcript"
45
+ className={cn("stg:size-7", className)}
46
+ >
47
+ {exporter.isExporting ? (
48
+ <SpinnerIcon size={14} />
49
+ ) : (
50
+ <Download className="stg:size-3.5" aria-hidden="true" />
51
+ )}
52
+ </ActionMenu.Trigger>
53
+ <ActionMenu.Content>
54
+ <ActionMenu.Item
55
+ icon={<Copy className="stg:size-3.5" aria-hidden="true" />}
56
+ onSelect={() => void exporter.copyMarkdown()}
57
+ disabled={exporter.isExporting}
58
+ >
59
+ Copy transcript
60
+ </ActionMenu.Item>
61
+ <ActionMenu.Item
62
+ icon={<FileText className="stg:size-3.5" aria-hidden="true" />}
63
+ onSelect={() => void exporter.downloadMarkdown()}
64
+ disabled={exporter.isExporting}
65
+ >
66
+ Download Markdown
67
+ </ActionMenu.Item>
68
+ <ActionMenu.Item
69
+ icon={<FileJson className="stg:size-3.5" aria-hidden="true" />}
70
+ onSelect={() => void exporter.downloadJson()}
71
+ disabled={exporter.isExporting}
72
+ >
73
+ Download JSON
74
+ </ActionMenu.Item>
75
+ </ActionMenu.Content>
76
+ </ActionMenu>
77
+ );
78
+ }
@@ -33,6 +33,7 @@ const emptyWorkspace = {
33
33
  clear: vi.fn(),
34
34
  };
35
35
 
36
+ const setAutoApproveAll = vi.fn();
36
37
  const stubEmptyFlow = {
37
38
  harness: "native" as const,
38
39
  setHarness: vi.fn(),
@@ -48,6 +49,8 @@ const stubEmptyFlow = {
48
49
  setSkillRefs: vi.fn(),
49
50
  workspace: emptyWorkspace,
50
51
  sessionVariables: { entries: [], isEmpty: true, clear: vi.fn() },
52
+ autoApproveAll: false,
53
+ setAutoApproveAll,
51
54
  isSubmitting: false,
52
55
  submitError: null,
53
56
  submit: vi.fn(),
@@ -125,6 +128,35 @@ describe("NewSessionViewer — persistent panel chip", () => {
125
128
  });
126
129
  });
127
130
 
131
+ describe("NewSessionViewer — auto-approve placement (#816 rework)", () => {
132
+ const INDICATOR_TEXT = "Auto-approving tool calls for this session";
133
+
134
+ it("shows no indicator while auto-approve is off — the launcher stays clean", () => {
135
+ render(<NewSessionViewer org="acme" onSessionCreated={vi.fn()} />);
136
+ expect(screen.queryByText(INDICATOR_TEXT)).toBeNull();
137
+ });
138
+
139
+ it("shows the armed-only indicator when a seed pre-arms the session, with Turn off", () => {
140
+ mockFlow = { ...stubEmptyFlow, autoApproveAll: true };
141
+ render(<NewSessionViewer org="acme" onSessionCreated={vi.fn()} />);
142
+
143
+ expect(screen.getByText(INDICATOR_TEXT)).toBeTruthy();
144
+ fireEvent.click(screen.getByRole("button", { name: "Turn off" }));
145
+ expect(setAutoApproveAll).toHaveBeenCalledExactlyOnceWith(false);
146
+ });
147
+
148
+ it("offers the auto-approve switch in the Config facet, wired to the flow", () => {
149
+ render(<NewSessionViewer org="acme" onSessionCreated={vi.fn()} />);
150
+
151
+ fireEvent.click(screen.getByRole("button", { name: "Show panel" }));
152
+ const toggle = screen.getByRole("switch", { name: "Auto-approve" });
153
+ expect(toggle.getAttribute("aria-checked")).toBe("false");
154
+
155
+ fireEvent.click(toggle);
156
+ expect(setAutoApproveAll).toHaveBeenCalledExactlyOnceWith(true);
157
+ });
158
+ });
159
+
128
160
  describe("NewSessionViewer — composer stays centered", () => {
129
161
  // The composer's wrapper is the probe's parent (wrapper > h1 + composer +
130
162
  // footer). `my-auto` is unconditional safe-centering; the old `my-6`
@@ -0,0 +1,244 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { render, screen, cleanup, fireEvent } from "@testing-library/react";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Wiring-contract tests for auto-approve placement (the #816 rework):
6
+ //
7
+ // - The composer carries NO auto-approve control — the session-level switch
8
+ // lives in the Config facet (SetupTab receives the `autoApprove` group for
9
+ // every audience that may submit approvals, never observers; guests never
10
+ // render the panel at all).
11
+ // - The armed-only AutoApproveIndicator above the composer is the
12
+ // always-visible disclosure: present exactly while the state is armed,
13
+ // with a one-click "Turn off"; never rendered for observers.
14
+ // ---------------------------------------------------------------------------
15
+
16
+ type CapturedProps = Record<string, unknown>;
17
+
18
+ const composerProps: CapturedProps[] = [];
19
+ vi.mock("../../composer", async (importOriginal) => {
20
+ const actual = await importOriginal<typeof import("../../composer")>();
21
+ return {
22
+ ...actual,
23
+ SessionComposer: (props: CapturedProps) => {
24
+ composerProps.push(props);
25
+ return <div data-testid="composer-probe" />;
26
+ },
27
+ };
28
+ });
29
+
30
+ vi.mock("../../execution/MessageThread", () => ({
31
+ MessageThread: () => <div data-testid="thread-probe" />,
32
+ }));
33
+
34
+ vi.mock("../../execution/FileReviewDock", () => ({
35
+ FileReviewDock: () => <div data-testid="file-review-dock-probe" />,
36
+ }));
37
+
38
+ // SetupTab renders for real: the facet switch is asserted through its
39
+ // actual DOM (a switch labeled "Auto-approve" in the Run Config section),
40
+ // reached by opening the panel and selecting the Config rail view.
41
+
42
+ const stubWorkspace = {
43
+ entries: [],
44
+ hasEntries: false,
45
+ toInput: vi.fn().mockReturnValue([]),
46
+ addGitRepo: vi.fn(),
47
+ addLocalPath: vi.fn(),
48
+ removeEntry: vi.fn(),
49
+ clear: vi.fn(),
50
+ };
51
+
52
+ const stubConv = {
53
+ session: { spec: {} } as Record<string, unknown>,
54
+ isLoading: false,
55
+ loadError: null,
56
+ completedExecutions: [],
57
+ activeStreamExecution: null,
58
+ activePhase: null,
59
+ isStreaming: false,
60
+ isConnecting: false,
61
+ sendFollowUp: vi.fn(),
62
+ canSendFollowUp: true,
63
+ isSending: false,
64
+ sendError: null,
65
+ clearSendError: vi.fn(),
66
+ pendingUserMessage: null,
67
+ workspaceEntries: [],
68
+ mcpServerUsages: [],
69
+ skillRefs: [],
70
+ pendingApprovals: [],
71
+ submitApproval: vi.fn(),
72
+ submittingApprovalIds: new Set<string>(),
73
+ fileChangeSets: [],
74
+ submitFileDecision: vi.fn(),
75
+ submittingFileDecisionKeys: new Set<string>(),
76
+ fileDecisionErrors: new Map<string, Error>(),
77
+ streamError: null,
78
+ reconnectStream: vi.fn(),
79
+ approvalError: null,
80
+ retryLastSend: vi.fn(),
81
+ };
82
+
83
+ const setAutoApproveAll = vi.fn();
84
+ const stubSessionPageFlow = {
85
+ conv: stubConv,
86
+ harness: "native" as const,
87
+ executionTarget: undefined,
88
+ model: [undefined, vi.fn()] as const,
89
+ interactionMode: ["agent" as const, vi.fn()] as const,
90
+ agentRef: { org: "acme", slug: "support-bot" },
91
+ setAgentRef: vi.fn(),
92
+ resolution: null,
93
+ setResolution: vi.fn(),
94
+ isDefaultAgent: false,
95
+ mcpServerUsages: [],
96
+ setMcpServerUsages: vi.fn(),
97
+ skillRefs: [],
98
+ setSkillRefs: vi.fn(),
99
+ workspace: stubWorkspace,
100
+ sessionVariables: { entries: [], isEmpty: true, clear: vi.fn() },
101
+ autoApproveAll: false,
102
+ setAutoApproveAll,
103
+ submitApproval: vi.fn(),
104
+ handleSubmit: vi.fn(),
105
+ submitError: null as Error | null,
106
+ displayExecution: null,
107
+ allExecutions: [],
108
+ sandboxWorkspaceRoot: undefined,
109
+ };
110
+ vi.mock("../useSessionPageFlow", () => ({
111
+ useSessionPageFlow: () => stubSessionPageFlow,
112
+ }));
113
+
114
+ vi.mock("../../hooks", () => ({
115
+ useStigmer: () => ({
116
+ agentExecution: {
117
+ uploadAttachment: vi.fn(),
118
+ getArtifactContent: vi.fn(),
119
+ },
120
+ }),
121
+ }));
122
+
123
+ import { SessionViewer } from "../SessionViewer";
124
+
125
+ const INDICATOR_TEXT = "Auto-approving tool calls for this session";
126
+
127
+ function lastComposerProps(): CapturedProps {
128
+ expect(composerProps.length).toBeGreaterThan(0);
129
+ return composerProps.at(-1)!;
130
+ }
131
+
132
+ /** Selects the panel's Config facet (the rail's radio). */
133
+ function openConfigFacet() {
134
+ fireEvent.click(screen.getByRole("radio", { name: "Config" }));
135
+ }
136
+
137
+ function facetSwitch(): HTMLElement | null {
138
+ return screen.queryByRole("switch", { name: "Auto-approve" });
139
+ }
140
+
141
+ beforeEach(() => {
142
+ composerProps.length = 0;
143
+ stubConv.session = { spec: {} };
144
+ stubSessionPageFlow.autoApproveAll = false;
145
+ });
146
+
147
+ afterEach(() => {
148
+ cleanup();
149
+ vi.clearAllMocks();
150
+ });
151
+
152
+ describe("SessionViewer — composer stays clean (#816 rework)", () => {
153
+ it("hands the composer no auto-approve control at all", () => {
154
+ render(<SessionViewer sessionId="ses_1" org="acme" />);
155
+
156
+ expect("autoApprove" in lastComposerProps()).toBe(false);
157
+ });
158
+ });
159
+
160
+ describe("SessionViewer — Config facet auto-approve switch (#816 rework)", () => {
161
+ it("renders the switch reflecting the flow state, and flipping calls the flow (integrator)", () => {
162
+ render(<SessionViewer sessionId="ses_1" org="acme" defaultPanelOpen />);
163
+ openConfigFacet();
164
+
165
+ const toggle = facetSwitch();
166
+ expect(toggle).not.toBeNull();
167
+ expect(toggle!.getAttribute("aria-checked")).toBe("false");
168
+ fireEvent.click(toggle!);
169
+ expect(setAutoApproveAll).toHaveBeenCalledExactlyOnceWith(true);
170
+ });
171
+
172
+ it("mirrors an armed flow state into the switch", () => {
173
+ stubSessionPageFlow.autoApproveAll = true;
174
+ render(<SessionViewer sessionId="ses_1" org="acme" defaultPanelOpen />);
175
+ openConfigFacet();
176
+
177
+ expect(facetSwitch()!.getAttribute("aria-checked")).toBe("true");
178
+ });
179
+
180
+ it("endUser gets the switch — the walk-away persona is an end user", () => {
181
+ render(
182
+ <SessionViewer
183
+ sessionId="ses_1"
184
+ org="acme"
185
+ audience="endUser"
186
+ defaultPanelOpen
187
+ />,
188
+ );
189
+ openConfigFacet();
190
+
191
+ expect(facetSwitch()).not.toBeNull();
192
+ });
193
+
194
+ it("observers never get the switch — the approval-submission withhold", () => {
195
+ render(
196
+ <SessionViewer
197
+ sessionId="ses_1"
198
+ org="acme"
199
+ audience="observer"
200
+ defaultPanelOpen
201
+ />,
202
+ );
203
+ openConfigFacet();
204
+
205
+ expect(facetSwitch()).toBeNull();
206
+ });
207
+
208
+ it("channel-origin sessions never get the switch (observer by construction)", () => {
209
+ stubConv.session = {
210
+ spec: {},
211
+ metadata: { labels: { "stigmer.ai/channel-id": "chn_1" } },
212
+ };
213
+ render(<SessionViewer sessionId="ses_1" org="acme" defaultPanelOpen />);
214
+ openConfigFacet();
215
+
216
+ expect(facetSwitch()).toBeNull();
217
+ });
218
+ });
219
+
220
+ describe("SessionViewer — armed-only indicator (#816 rework)", () => {
221
+ it("absent while auto-approve is off — the composer stays clean", () => {
222
+ render(<SessionViewer sessionId="ses_1" org="acme" />);
223
+
224
+ expect(screen.queryByText(INDICATOR_TEXT)).toBeNull();
225
+ });
226
+
227
+ it("renders while armed, and Turn off flips the flow state", () => {
228
+ stubSessionPageFlow.autoApproveAll = true;
229
+ render(<SessionViewer sessionId="ses_1" org="acme" />);
230
+
231
+ expect(screen.getByText(INDICATOR_TEXT)).toBeTruthy();
232
+ fireEvent.click(screen.getByRole("button", { name: "Turn off" }));
233
+ expect(setAutoApproveAll).toHaveBeenCalledExactlyOnceWith(false);
234
+ });
235
+
236
+ it("never renders for observers — they cannot arm or disarm anything", () => {
237
+ stubSessionPageFlow.autoApproveAll = true;
238
+ render(
239
+ <SessionViewer sessionId="ses_1" org="acme" audience="observer" />,
240
+ );
241
+
242
+ expect(screen.queryByText(INDICATOR_TEXT)).toBeNull();
243
+ });
244
+ });
@@ -0,0 +1,189 @@
1
+ // SessionViewer's built-in transcript export (stigmer/stigmer#814, placement
2
+ // reworked with #816): export lives in the Config facet's Transcript section
3
+ // for viewers with the session panel, and the header TranscriptExportMenu
4
+ // remains ONLY as the panel-less fallback (guests, panel="none") — so
5
+ // wherever a conversation is viewed, its transcript stays reachable, without
6
+ // a floating header button on surfaces that have a proper home for it.
7
+
8
+ import { describe, it, expect, vi, afterEach } from "vitest";
9
+ import { render, screen, cleanup, fireEvent } from "@testing-library/react";
10
+
11
+ vi.mock("../../composer", async (importOriginal) => {
12
+ const actual = await importOriginal<typeof import("../../composer")>();
13
+ return {
14
+ ...actual,
15
+ SessionComposer: () => <div data-testid="composer-probe" />,
16
+ };
17
+ });
18
+ vi.mock("../../execution/MessageThread", () => ({
19
+ MessageThread: () => <div data-testid="thread-probe" />,
20
+ }));
21
+ vi.mock("../../execution/FileReviewDock", () => ({
22
+ FileReviewDock: () => <div data-testid="file-review-dock-probe" />,
23
+ }));
24
+
25
+ // SetupTab renders for real: the facet's Transcript section is asserted
26
+ // through its actual DOM (the "Copy transcript" action row), reached by
27
+ // opening the panel and selecting the Config rail view.
28
+
29
+ const stubWorkspace = {
30
+ entries: [],
31
+ hasEntries: false,
32
+ toInput: vi.fn().mockReturnValue([]),
33
+ addGitRepo: vi.fn(),
34
+ addLocalPath: vi.fn(),
35
+ removeEntry: vi.fn(),
36
+ clear: vi.fn(),
37
+ };
38
+
39
+ const stubConv = {
40
+ session: { metadata: { id: "ses_1", org: "acme" }, spec: {} },
41
+ isLoading: false,
42
+ loadError: null,
43
+ completedExecutions: [],
44
+ activeStreamExecution: null,
45
+ activePhase: null,
46
+ isStreaming: false,
47
+ isConnecting: false,
48
+ sendFollowUp: vi.fn(),
49
+ canSendFollowUp: true,
50
+ isSending: false,
51
+ sendError: null,
52
+ clearSendError: vi.fn(),
53
+ pendingUserMessage: null,
54
+ workspaceEntries: [],
55
+ mcpServerUsages: [],
56
+ skillRefs: [],
57
+ pendingApprovals: [],
58
+ submitApproval: vi.fn(),
59
+ submittingApprovalIds: new Set<string>(),
60
+ fileChangeSets: [],
61
+ submitFileDecision: vi.fn(),
62
+ submittingFileDecisionKeys: new Set<string>(),
63
+ fileDecisionErrors: new Map<string, Error>(),
64
+ streamError: null,
65
+ reconnectStream: vi.fn(),
66
+ approvalError: null,
67
+ retryLastSend: vi.fn(),
68
+ };
69
+
70
+ vi.mock("../useSessionPageFlow", () => ({
71
+ useSessionPageFlow: () => ({
72
+ conv: stubConv,
73
+ harness: "native" as const,
74
+ executionTarget: undefined,
75
+ model: [undefined, vi.fn()] as const,
76
+ interactionMode: ["agent" as const, vi.fn()] as const,
77
+ agentRef: { org: "acme", slug: "support-bot" },
78
+ setAgentRef: vi.fn(),
79
+ resolution: null,
80
+ setResolution: vi.fn(),
81
+ isDefaultAgent: false,
82
+ mcpServerUsages: [],
83
+ setMcpServerUsages: vi.fn(),
84
+ skillRefs: [],
85
+ setSkillRefs: vi.fn(),
86
+ workspace: stubWorkspace,
87
+ sessionVariables: { entries: [], isEmpty: true, clear: vi.fn() },
88
+ autoApproveAll: false,
89
+ setAutoApproveAll: vi.fn(),
90
+ submitApproval: vi.fn(),
91
+ handleSubmit: vi.fn(),
92
+ submitError: null as Error | null,
93
+ displayExecution: null,
94
+ allExecutions: [],
95
+ sandboxWorkspaceRoot: undefined,
96
+ }),
97
+ }));
98
+
99
+ vi.mock("../../hooks", () => ({
100
+ useStigmer: () => ({
101
+ session: { get: vi.fn() },
102
+ agentExecution: {
103
+ uploadAttachment: vi.fn(),
104
+ getArtifactContent: vi.fn(),
105
+ listBySession: vi.fn(),
106
+ },
107
+ }),
108
+ }));
109
+
110
+ import { SessionViewer } from "../SessionViewer";
111
+
112
+ afterEach(() => {
113
+ cleanup();
114
+ vi.clearAllMocks();
115
+ });
116
+
117
+ function headerExportTrigger(): HTMLElement | null {
118
+ return screen.queryByRole("button", { name: "Export transcript" });
119
+ }
120
+
121
+ /** Selects the panel's Config facet (the rail's radio). */
122
+ function openConfigFacet() {
123
+ fireEvent.click(screen.getByRole("radio", { name: "Config" }));
124
+ }
125
+
126
+ function facetCopyAction(): HTMLElement | null {
127
+ return screen.queryByRole("button", { name: "Copy transcript" });
128
+ }
129
+
130
+ describe("SessionViewer transcript export placement", () => {
131
+ it("panel-enabled viewers export from the Config facet, not the header", () => {
132
+ render(<SessionViewer sessionId="ses_1" org="acme" defaultPanelOpen />);
133
+ expect(headerExportTrigger()).toBeNull();
134
+ openConfigFacet();
135
+ expect(facetCopyAction()).not.toBeNull();
136
+ });
137
+
138
+ it("observers keep facet export — a bulk copy of what they already see", () => {
139
+ render(
140
+ <SessionViewer
141
+ sessionId="ses_1"
142
+ org="acme"
143
+ audience="observer"
144
+ defaultPanelOpen
145
+ />,
146
+ );
147
+ openConfigFacet();
148
+ expect(facetCopyAction()).not.toBeNull();
149
+ });
150
+
151
+ it("panel=\"none\" hosts fall back to the header menu", () => {
152
+ render(<SessionViewer sessionId="ses_1" org="acme" panel="none" />);
153
+ expect(headerExportTrigger()).not.toBeNull();
154
+ });
155
+
156
+ it("guests fall back to the header menu (no panel to reach the facet)", () => {
157
+ render(<SessionViewer sessionId="ses_1" org="acme" audience="guest" />);
158
+ expect(headerExportTrigger()).not.toBeNull();
159
+ });
160
+
161
+ it("the header fallback composes with host headerActions", () => {
162
+ render(
163
+ <SessionViewer
164
+ sessionId="ses_1"
165
+ org="acme"
166
+ panel="none"
167
+ headerActions={<button>Share</button>}
168
+ />,
169
+ );
170
+ expect(screen.getByRole("button", { name: "Share" })).toBeTruthy();
171
+ expect(headerExportTrigger()).not.toBeNull();
172
+ });
173
+
174
+ it("explicit opt-out removes BOTH surfaces, leaving host actions intact", () => {
175
+ render(
176
+ <SessionViewer
177
+ sessionId="ses_1"
178
+ org="acme"
179
+ transcriptExport={false}
180
+ defaultPanelOpen
181
+ headerActions={<button>Share</button>}
182
+ />,
183
+ );
184
+ expect(headerExportTrigger()).toBeNull();
185
+ openConfigFacet();
186
+ expect(facetCopyAction()).toBeNull();
187
+ expect(screen.getByRole("button", { name: "Share" })).toBeTruthy();
188
+ });
189
+ });
@@ -0,0 +1,97 @@
1
+ // TranscriptExportMenu (stigmer/stigmer#814): the control's wiring — labels,
2
+ // action dispatch, busy state. The menu chrome itself (Base UI portal
3
+ // behavior) is the ActionMenu compound's concern; it is faked transparent
4
+ // here so the assertions target this component's own contract.
5
+
6
+ import { describe, it, expect, vi, beforeEach } from "vitest";
7
+ import { render, screen, cleanup, fireEvent } from "@testing-library/react";
8
+ import type { ReactNode } from "react";
9
+
10
+ vi.mock("../../action-menu/ActionMenu", () => {
11
+ function Root({ children }: { children: ReactNode }) {
12
+ return <div>{children}</div>;
13
+ }
14
+ return {
15
+ ActionMenu: Object.assign(Root, {
16
+ Trigger: ({
17
+ children,
18
+ "aria-label": ariaLabel,
19
+ }: {
20
+ children: ReactNode;
21
+ "aria-label"?: string;
22
+ }) => <button aria-label={ariaLabel}>{children}</button>,
23
+ Content: ({ children }: { children: ReactNode }) => <div>{children}</div>,
24
+ Item: ({
25
+ children,
26
+ onSelect,
27
+ disabled,
28
+ }: {
29
+ children: ReactNode;
30
+ onSelect?: () => void;
31
+ disabled?: boolean;
32
+ }) => (
33
+ <button onClick={onSelect} disabled={disabled}>
34
+ {children}
35
+ </button>
36
+ ),
37
+ }),
38
+ };
39
+ });
40
+
41
+ const exporter = {
42
+ copyMarkdown: vi.fn(() => Promise.resolve()),
43
+ downloadMarkdown: vi.fn(() => Promise.resolve()),
44
+ downloadJson: vi.fn(() => Promise.resolve()),
45
+ isExporting: false,
46
+ error: null as Error | null,
47
+ };
48
+ const useExportTranscript = vi.fn(
49
+ (_sessionId: string | null, _options?: unknown) => exporter,
50
+ );
51
+ vi.mock("../useExportTranscript", () => ({
52
+ useExportTranscript: (sessionId: string | null, options?: unknown) =>
53
+ useExportTranscript(sessionId, options),
54
+ }));
55
+
56
+ import { TranscriptExportMenu } from "../TranscriptExportMenu";
57
+
58
+ beforeEach(() => {
59
+ vi.clearAllMocks();
60
+ exporter.isExporting = false;
61
+ cleanup();
62
+ });
63
+
64
+ describe("TranscriptExportMenu", () => {
65
+ it("offers the three export actions and dispatches to the hook", () => {
66
+ render(<TranscriptExportMenu sessionId="ses_01" />);
67
+
68
+ fireEvent.click(screen.getByText("Copy transcript"));
69
+ fireEvent.click(screen.getByText("Download Markdown"));
70
+ fireEvent.click(screen.getByText("Download JSON"));
71
+
72
+ expect(exporter.copyMarkdown).toHaveBeenCalledTimes(1);
73
+ expect(exporter.downloadMarkdown).toHaveBeenCalledTimes(1);
74
+ expect(exporter.downloadJson).toHaveBeenCalledTimes(1);
75
+ });
76
+
77
+ it("labels the trigger for assistive tech", () => {
78
+ render(<TranscriptExportMenu sessionId="ses_01" />);
79
+ expect(
80
+ screen.getByRole("button", { name: "Export transcript" }),
81
+ ).toBeTruthy();
82
+ });
83
+
84
+ it("disables the actions while an export is in flight", () => {
85
+ exporter.isExporting = true;
86
+ render(<TranscriptExportMenu sessionId="ses_01" />);
87
+ const item = screen.getByText("Copy transcript").closest("button");
88
+ expect(item?.disabled).toBe(true);
89
+ });
90
+
91
+ it("forwards includeSuperseded to the hook", () => {
92
+ render(<TranscriptExportMenu sessionId="ses_01" includeSuperseded />);
93
+ expect(useExportTranscript).toHaveBeenCalledWith("ses_01", {
94
+ includeSuperseded: true,
95
+ });
96
+ });
97
+ });