@stigmer/react 3.12.7 → 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 (137) hide show
  1. package/composer/ComposerToolbar.d.ts +1 -19
  2. package/composer/ComposerToolbar.d.ts.map +1 -1
  3. package/composer/ComposerToolbar.js +2 -7
  4. package/composer/ComposerToolbar.js.map +1 -1
  5. package/composer/SessionComposer.d.ts +0 -10
  6. package/composer/SessionComposer.d.ts.map +1 -1
  7. package/composer/SessionComposer.js +2 -2
  8. package/composer/SessionComposer.js.map +1 -1
  9. package/composer/index.d.ts +0 -1
  10. package/composer/index.d.ts.map +1 -1
  11. package/composer/index.js.map +1 -1
  12. package/execution/ResultView.d.ts.map +1 -1
  13. package/execution/ResultView.js +6 -0
  14. package/execution/ResultView.js.map +1 -1
  15. package/execution/ToolCallItem.d.ts.map +1 -1
  16. package/execution/ToolCallItem.js +13 -2
  17. package/execution/ToolCallItem.js.map +1 -1
  18. package/execution/tool-categories.d.ts +1 -1
  19. package/execution/tool-categories.d.ts.map +1 -1
  20. package/execution/tool-categories.js +6 -0
  21. package/execution/tool-categories.js.map +1 -1
  22. package/identity-account/AccountPreferencesPanel.d.ts +2 -1
  23. package/identity-account/AccountPreferencesPanel.d.ts.map +1 -1
  24. package/identity-account/AccountPreferencesPanel.js +41 -3
  25. package/identity-account/AccountPreferencesPanel.js.map +1 -1
  26. package/identity-account/useAccountExecutionDefaults.d.ts +7 -0
  27. package/identity-account/useAccountExecutionDefaults.d.ts.map +1 -1
  28. package/identity-account/useAccountExecutionDefaults.js +5 -2
  29. package/identity-account/useAccountExecutionDefaults.js.map +1 -1
  30. package/index.d.ts +4 -4
  31. package/index.d.ts.map +1 -1
  32. package/index.js +1 -1
  33. package/index.js.map +1 -1
  34. package/internal/MemoryEnabledRow.d.ts +37 -0
  35. package/internal/MemoryEnabledRow.d.ts.map +1 -0
  36. package/internal/MemoryEnabledRow.js +25 -0
  37. package/internal/MemoryEnabledRow.js.map +1 -0
  38. package/memory/MemoryProposalCard.d.ts +35 -0
  39. package/memory/MemoryProposalCard.d.ts.map +1 -0
  40. package/memory/MemoryProposalCard.js +73 -0
  41. package/memory/MemoryProposalCard.js.map +1 -0
  42. package/memory/index.d.ts +2 -0
  43. package/memory/index.d.ts.map +1 -1
  44. package/memory/index.js +2 -0
  45. package/memory/index.js.map +1 -1
  46. package/memory/useMemory.d.ts +39 -0
  47. package/memory/useMemory.d.ts.map +1 -0
  48. package/memory/useMemory.js +43 -0
  49. package/memory/useMemory.js.map +1 -0
  50. package/organization/OrgPreferencesPanel.d.ts.map +1 -1
  51. package/organization/OrgPreferencesPanel.js +36 -4
  52. package/organization/OrgPreferencesPanel.js.map +1 -1
  53. package/package.json +4 -4
  54. package/session/AutoApproveIndicator.d.ts +18 -0
  55. package/session/AutoApproveIndicator.d.ts.map +1 -0
  56. package/session/AutoApproveIndicator.js +23 -0
  57. package/session/AutoApproveIndicator.js.map +1 -0
  58. package/session/NewSessionViewer.d.ts.map +1 -1
  59. package/session/NewSessionViewer.js +10 -9
  60. package/session/NewSessionViewer.js.map +1 -1
  61. package/session/SessionViewer.d.ts +21 -9
  62. package/session/SessionViewer.d.ts.map +1 -1
  63. package/session/SessionViewer.js +31 -21
  64. package/session/SessionViewer.js.map +1 -1
  65. package/session/facets/SetupTab.d.ts +43 -6
  66. package/session/facets/SetupTab.d.ts.map +1 -1
  67. package/session/facets/SetupTab.js +32 -36
  68. package/session/facets/SetupTab.js.map +1 -1
  69. package/session/facets/UsageTab.d.ts +2 -1
  70. package/session/facets/UsageTab.d.ts.map +1 -1
  71. package/session/facets/UsageTab.js +11 -5
  72. package/session/facets/UsageTab.js.map +1 -1
  73. package/session/facets/index.d.ts +1 -1
  74. package/session/facets/index.d.ts.map +1 -1
  75. package/session/facets/primitives.d.ts +58 -0
  76. package/session/facets/primitives.d.ts.map +1 -0
  77. package/session/facets/primitives.js +59 -0
  78. package/session/facets/primitives.js.map +1 -0
  79. package/session/index.d.ts +1 -1
  80. package/session/index.d.ts.map +1 -1
  81. package/session/index.js.map +1 -1
  82. package/session/useNewSessionFlow.d.ts +9 -4
  83. package/session/useNewSessionFlow.d.ts.map +1 -1
  84. package/session/useNewSessionFlow.js +20 -9
  85. package/session/useNewSessionFlow.js.map +1 -1
  86. package/session/useSessionPageFlow.d.ts +28 -10
  87. package/session/useSessionPageFlow.d.ts.map +1 -1
  88. package/session/useSessionPageFlow.js +16 -10
  89. package/session/useSessionPageFlow.js.map +1 -1
  90. package/src/attachment/__tests__/prepare-image.test.ts +12 -6
  91. package/src/composer/ComposerToolbar.tsx +0 -57
  92. package/src/composer/SessionComposer.tsx +1 -13
  93. package/src/composer/index.ts +0 -1
  94. package/src/execution/ResultView.tsx +6 -0
  95. package/src/execution/ToolCallItem.tsx +18 -1
  96. package/src/execution/tool-categories.ts +7 -0
  97. package/src/identity-account/AccountPreferencesPanel.tsx +89 -2
  98. package/src/identity-account/__tests__/AccountPreferencesPanel.test.tsx +112 -0
  99. package/src/identity-account/__tests__/useAccountExecutionDefaults.test.tsx +34 -1
  100. package/src/identity-account/useAccountExecutionDefaults.ts +12 -2
  101. package/src/index.ts +5 -1
  102. package/src/internal/MemoryEnabledRow.tsx +92 -0
  103. package/src/memory/MemoryProposalCard.tsx +181 -0
  104. package/src/memory/__tests__/MemoryProposalCard.test.tsx +236 -0
  105. package/src/memory/__tests__/memoryHooks.test.tsx +63 -0
  106. package/src/memory/index.ts +5 -0
  107. package/src/memory/useMemory.ts +80 -0
  108. package/src/organization/OrgPreferencesPanel.tsx +52 -3
  109. package/src/organization/__tests__/OrgPreferencesPanel.test.tsx +65 -0
  110. package/src/session/AutoApproveIndicator.tsx +45 -0
  111. package/src/session/NewSessionViewer.tsx +18 -15
  112. package/src/session/SessionViewer.tsx +70 -32
  113. package/src/session/__tests__/NewSessionViewer.test.tsx +32 -0
  114. package/src/session/__tests__/SessionViewer-autoApprove.test.tsx +244 -0
  115. package/src/session/__tests__/SessionViewer-transcriptExport.test.tsx +60 -22
  116. package/src/session/__tests__/useNewSessionFlow.test.tsx +56 -0
  117. package/src/session/__tests__/useSessionPageFlow.autoApprove.test.tsx +87 -0
  118. package/src/session/facets/SetupTab.tsx +193 -157
  119. package/src/session/facets/UsageTab.tsx +38 -38
  120. package/src/session/facets/index.ts +1 -0
  121. package/src/session/facets/primitives.tsx +169 -0
  122. package/src/session/index.ts +1 -0
  123. package/src/session/useNewSessionFlow.ts +30 -15
  124. package/src/session/useSessionPageFlow.ts +44 -20
  125. package/src/switch/Switch.tsx +4 -0
  126. package/src/workflow/__tests__/useWorkflowCanvas.dirty.test.tsx +7 -2
  127. package/src/workflow/__tests__/useWorkflowCanvas.drag.test.tsx +7 -2
  128. package/src/workspace/WorkspaceSurface.tsx +26 -3
  129. package/styles.css +1 -1
  130. package/switch/Switch.d.ts +3 -1
  131. package/switch/Switch.d.ts.map +1 -1
  132. package/switch/Switch.js +2 -2
  133. package/switch/Switch.js.map +1 -1
  134. package/workspace/WorkspaceSurface.js +11 -2
  135. package/workspace/WorkspaceSurface.js.map +1 -1
  136. package/src/composer/__tests__/ComposerToolbar-autoApprove.test.tsx +0 -80
  137. package/src/session/__tests__/SessionViewer-autoApproveToggle.test.tsx +0 -178
@@ -12,7 +12,7 @@ import { DeploymentModeContext } from "../../deployment-mode";
12
12
  import { useAccountExecutionDefaults } from "../useAccountExecutionDefaults";
13
13
 
14
14
  function accountWithPreferences(
15
- preferences: Record<string, string>,
15
+ preferences: Record<string, string | boolean>,
16
16
  ): IdentityAccount {
17
17
  return create(IdentityAccountSchema, {
18
18
  metadata: { id: "ia-1", name: "Ada", slug: "ada", org: "acme" },
@@ -102,6 +102,39 @@ describe("useAccountExecutionDefaults", () => {
102
102
  );
103
103
  });
104
104
 
105
+ it("a declared auto-approve default alone counts as a declared default", async () => {
106
+ const whoAmI = vi.fn(async () =>
107
+ accountWithPreferences({ defaultAutoApprove: true }),
108
+ );
109
+ const { result } = renderHook(() => useAccountExecutionDefaults(), {
110
+ wrapper: createWrapper(whoAmI),
111
+ });
112
+
113
+ await waitFor(() =>
114
+ expect(result.current).toEqual({
115
+ harness: undefined,
116
+ nativeModel: undefined,
117
+ cursorModel: undefined,
118
+ autoApprove: true,
119
+ }),
120
+ );
121
+ });
122
+
123
+ it("an unset auto-approve bool is no preference, not an explicit false", async () => {
124
+ const whoAmI = vi.fn(async () =>
125
+ accountWithPreferences({
126
+ defaultHarness: "native",
127
+ defaultAutoApprove: false,
128
+ }),
129
+ );
130
+ const { result } = renderHook(() => useAccountExecutionDefaults(), {
131
+ wrapper: createWrapper(whoAmI),
132
+ });
133
+
134
+ await waitFor(() => expect(result.current?.harness).toBe("native"));
135
+ expect(result.current?.autoApprove).toBeUndefined();
136
+ });
137
+
105
138
  it("returns undefined on a whoAmI failure — degrades to platform defaults", async () => {
106
139
  const whoAmI = vi.fn(async () => {
107
140
  throw new Error("boom");
@@ -16,6 +16,13 @@ export interface AccountExecutionDefaults {
16
16
  readonly nativeModel?: string;
17
17
  /** Preferred model for cursor-harness sessions, when declared. */
18
18
  readonly cursorModel?: string;
19
+ /**
20
+ * Start sessions with "auto-approve tool calls" armed
21
+ * (`default_auto_approve`, stigmer/stigmer#816). A seed like every other
22
+ * field here: an explicit in-session flip beats it for that conversation,
23
+ * and it is never applied to guest/observer surfaces.
24
+ */
25
+ readonly autoApprove?: boolean;
19
26
  }
20
27
 
21
28
  /**
@@ -56,8 +63,11 @@ export function useAccountExecutionDefaults(): AccountExecutionDefaults | undefi
56
63
  : undefined;
57
64
  const nativeModel = prefs.defaultNativeModel || undefined;
58
65
  const cursorModel = prefs.defaultCursorModel || undefined;
66
+ // Only a declared `true` travels — an unset bool is "no preference",
67
+ // matching the empty-string convention of the fields above.
68
+ const autoApprove = prefs.defaultAutoApprove || undefined;
59
69
 
60
- if (!harness && !nativeModel && !cursorModel) return undefined;
61
- return { harness, nativeModel, cursorModel };
70
+ if (!harness && !nativeModel && !cursorModel && !autoApprove) return undefined;
71
+ return { harness, nativeModel, cursorModel, autoApprove };
62
72
  }, [account]);
63
73
  }
package/src/index.ts CHANGED
@@ -234,6 +234,7 @@ export type {
234
234
  SessionRunConfig,
235
235
  SetupTabProps,
236
236
  SetupTabMutationCallbacks,
237
+ SetupTabAutoApprove,
237
238
  } from "./session/index.js";
238
239
 
239
240
  // Activity — unified recent activity (sessions + workflow executions)
@@ -551,7 +552,6 @@ export type {
551
552
  SessionComposerHandle,
552
553
  SessionComposerProps,
553
554
  SessionComposerSubmitContext,
554
- ComposerAutoApproveProps,
555
555
  InteractionModePickerProps,
556
556
  InteractionModeOption,
557
557
  } from "./composer/index.js";
@@ -866,6 +866,7 @@ export type {
866
866
  // hooks (confirm/reject/delete/edit), grouping helpers, and the list panel
867
867
  export {
868
868
  useMemories,
869
+ useMemory,
869
870
  useConfirmMemory,
870
871
  useRejectMemory,
871
872
  useDeleteMemory,
@@ -873,15 +874,18 @@ export {
873
874
  groupMemoriesByLifecycle,
874
875
  formatMemoryProvenance,
875
876
  MemoryListPanel,
877
+ MemoryProposalCardBody,
876
878
  } from "./memory/index.js";
877
879
  export type {
878
880
  UseMemoriesReturn,
881
+ UseMemoryReturn,
879
882
  UseConfirmMemoryReturn,
880
883
  UseRejectMemoryReturn,
881
884
  UseDeleteMemoryReturn,
882
885
  UseUpdateMemoryContentReturn,
883
886
  MemoryGroups,
884
887
  MemoryListPanelProps,
888
+ MemoryProposalCardBodyProps,
885
889
  } from "./memory/index.js";
886
890
 
887
891
  // IAM Policy — data hooks, behavior hooks, headless hook, and styled components for access management
@@ -0,0 +1,92 @@
1
+ "use client";
2
+
3
+ import { cn } from "@stigmer/theme";
4
+ import { getUserMessage } from "@stigmer/sdk";
5
+ import { Switch } from "../switch/Switch.js";
6
+ import { SpinnerIcon } from "./SpinnerIcon.js";
7
+
8
+ /** Props for {@link MemoryEnabledRow}. */
9
+ export interface MemoryEnabledRowProps {
10
+ /** Id for the switch control (aria-labelledby wiring). */
11
+ readonly id: string;
12
+ /** The stored flag value (server state, or an optimistic pending value). */
13
+ readonly checked: boolean;
14
+ /** Fired with the next value when the user flips the switch. */
15
+ readonly onToggle: (next: boolean) => void;
16
+ /** `true` while the flip is being saved (switch disabled, spinner shown). */
17
+ readonly saving: boolean;
18
+ /** `true` when the caller may not edit the flag (read-only rendering). */
19
+ readonly readOnly?: boolean;
20
+ /** Error from the last failed flip, or `null`. */
21
+ readonly error: Error | null;
22
+ /**
23
+ * The scope-specific helper copy (DD-006 D6's transparency statement
24
+ * belongs here). Rendered under the title, wired as the switch's
25
+ * accessible description.
26
+ */
27
+ readonly helperText: string;
28
+ }
29
+
30
+ /**
31
+ * The `memory_enabled` consent toggle — one row, shared verbatim by
32
+ * {@link OrgPreferencesPanel} and {@link AccountPreferencesPanel} so the
33
+ * two scopes of the double opt-in (DD-006 D1) present identically.
34
+ *
35
+ * INSTANT-APPLY by deliberate exception (UX checkpoint, owner-approved
36
+ * 2026-08-22): both host panels are save-button forms, but a consent bit
37
+ * flipped-and-unsaved that silently reverts on navigation is exactly the
38
+ * failure consent UX must not have — so this row saves the moment it is
39
+ * flipped, matching the Switch primitive's own contract ("instant binary
40
+ * changes") and every peer product's memory toggle. The helper copy
41
+ * states it. Hosts wire `onToggle` to their own full-spec-replace update
42
+ * and pass the in-flight/error state back down.
43
+ */
44
+ export function MemoryEnabledRow({
45
+ id,
46
+ checked,
47
+ onToggle,
48
+ saving,
49
+ readOnly = false,
50
+ error,
51
+ helperText,
52
+ }: MemoryEnabledRowProps) {
53
+ const titleId = `${id}-title`;
54
+ const descriptionId = `${id}-description`;
55
+
56
+ return (
57
+ <div className="stg:space-y-1">
58
+ <div className="stg:flex stg:items-start stg:justify-between stg:gap-3">
59
+ <div className="stg:min-w-0 stg:flex-1">
60
+ <span
61
+ id={titleId}
62
+ className="stg:block stg:text-xs stg:font-medium stg:text-foreground"
63
+ >
64
+ Memory
65
+ </span>
66
+ <p
67
+ id={descriptionId}
68
+ className="stg:text-[0.65rem] stg:leading-snug stg:text-muted-foreground"
69
+ >
70
+ {helperText}
71
+ </p>
72
+ </div>
73
+ <span className="stg:flex stg:shrink-0 stg:items-center stg:gap-1.5">
74
+ {saving && <SpinnerIcon size={12} />}
75
+ <Switch
76
+ id={id}
77
+ checked={checked}
78
+ onCheckedChange={onToggle}
79
+ disabled={saving || readOnly}
80
+ aria-labelledby={titleId}
81
+ aria-describedby={descriptionId}
82
+ />
83
+ </span>
84
+ </div>
85
+ {error && (
86
+ <p className="stg:text-[0.65rem] stg:text-destructive" role="alert">
87
+ {getUserMessage(error)}
88
+ </p>
89
+ )}
90
+ </div>
91
+ );
92
+ }
@@ -0,0 +1,181 @@
1
+ "use client";
2
+
3
+ import { useCallback, useState } from "react";
4
+ import { cn } from "@stigmer/theme";
5
+ import { getUserMessage } from "@stigmer/sdk";
6
+ import type { Memory } from "@stigmer/protos/ai/stigmer/agentic/memory/v1/api_pb";
7
+ import { MemoryLifecycleState } from "@stigmer/protos/ai/stigmer/agentic/memory/v1/enum_pb";
8
+ import { SpinnerIcon } from "../internal/SpinnerIcon.js";
9
+ import { formatMemoryProvenance } from "./memoryGroups.js";
10
+ import { useConfirmMemory } from "./useConfirmMemory.js";
11
+ import { useMemory } from "./useMemory.js";
12
+ import { useRejectMemory } from "./useRejectMemory.js";
13
+
14
+ /** Props for {@link MemoryProposalCardBody}. */
15
+ export interface MemoryProposalCardBodyProps {
16
+ /** The proposed memory's resource id (from the remember tool's answer). */
17
+ readonly memoryId: string;
18
+ /**
19
+ * The proposed fact as frozen in the tool result — shown VERBATIM
20
+ * (DD-005 D6: what you confirm is what future prompts inject, byte for
21
+ * byte). The live record's text supersedes it once fetched, so an edit
22
+ * made on the memory page is reflected here too.
23
+ */
24
+ readonly fact: string;
25
+ /** Additional CSS class names for the root container. */
26
+ readonly className?: string;
27
+ }
28
+
29
+ /**
30
+ * The in-session consent surface for a memory proposal (DD-005 D4): the
31
+ * body of a `remember` tool call's row in the message thread, rendering
32
+ * the proposed fact verbatim with one-click Confirm / Reject actions.
33
+ *
34
+ * Lifecycle honesty across surfaces and reloads: the tool result this
35
+ * renders from is FROZEN at capture time, so the card fetches the
36
+ * record's current state ({@link useMemory}) — a proposal decided from
37
+ * the memory settings page (or another device) shows its decided state
38
+ * here instead of stale action buttons, and a deleted record reads
39
+ * "no longer stored" rather than failing. Rejection is one click, no
40
+ * confirmation dialog (the T04 Cursor lesson: expensive review teaches
41
+ * users to ignore the queue); deletion-with-confirmation lives on the
42
+ * memory page, the catch-up surface.
43
+ *
44
+ * Borderless by design (the {@link ApprovalCardBody} posture): the tool
45
+ * row's card owns the chrome, this component owns only the body. All
46
+ * visual properties flow through `--stgm-*` design tokens.
47
+ */
48
+ export function MemoryProposalCardBody({
49
+ memoryId,
50
+ fact,
51
+ className,
52
+ }: MemoryProposalCardBodyProps) {
53
+ const { memory, isLoading, notFound, error: fetchError } = useMemory(memoryId);
54
+ const { confirmMemory, isConfirming, error: confirmError } = useConfirmMemory();
55
+ const { rejectMemory, isRejecting, error: rejectError } = useRejectMemory();
56
+
57
+ // The card's own decision lands here so the settled state renders
58
+ // immediately — the fetch stays the authority for every OTHER writer
59
+ // (the memory page, another device), read once on mount.
60
+ const [decided, setDecided] = useState<Memory | null>(null);
61
+
62
+ const act = useCallback(
63
+ async (action: (id: string) => Promise<Memory>) => {
64
+ try {
65
+ setDecided(await action(memoryId));
66
+ } catch {
67
+ // Error state is surfaced via the hooks.
68
+ }
69
+ },
70
+ [memoryId],
71
+ );
72
+
73
+ const record = decided ?? memory;
74
+ const state = record?.status?.lifecycleState;
75
+ // The live record's text supersedes the frozen tool result (the subject
76
+ // may have edited the fact on the memory page before deciding).
77
+ const displayFact = record?.spec?.content || fact;
78
+ const provenance = record ? formatMemoryProvenance(record) : null;
79
+ const actionError = confirmError ?? rejectError;
80
+ const isBusy = isConfirming || isRejecting;
81
+
82
+ const showActions =
83
+ !notFound &&
84
+ fetchError == null &&
85
+ (state === undefined ||
86
+ state === MemoryLifecycleState.lifecycle_state_unspecified ||
87
+ state === MemoryLifecycleState.lifecycle_state_proposed);
88
+
89
+ return (
90
+ <div className={cn("stg:space-y-2", className)} data-cursor-target="memory-proposal">
91
+ <p className="stg:text-sm stg:text-foreground stg:whitespace-pre-wrap">
92
+ {displayFact}
93
+ </p>
94
+
95
+ <p className="stg:text-xs stg:text-muted-foreground">
96
+ {provenance ? `${provenance} · ` : ""}
97
+ Proposed to remember — nothing is stored in your sessions until you
98
+ confirm it.
99
+ </p>
100
+
101
+ {notFound ? (
102
+ <p className="stg:text-xs stg:text-muted-foreground" role="status">
103
+ No longer stored.
104
+ </p>
105
+ ) : fetchError ? (
106
+ // The fact stays visible (it is frozen in the tool result); only
107
+ // the actionable state is unavailable. Decide from the memory
108
+ // page instead — the catch-up surface.
109
+ <p className="stg:text-xs stg:text-muted-foreground" role="status">
110
+ Couldn&apos;t load this proposal&apos;s current state — review it in
111
+ Settings → Memory.
112
+ </p>
113
+ ) : showActions ? (
114
+ <div className="stg:flex stg:items-center stg:gap-1.5">
115
+ <ChipButton
116
+ label={`Confirm memory: ${displayFact}`}
117
+ onClick={() => void act(confirmMemory)}
118
+ disabled={isBusy || isLoading}
119
+ emphasis="primary"
120
+ >
121
+ {isConfirming && <SpinnerIcon size={12} />}
122
+ Confirm
123
+ </ChipButton>
124
+ <ChipButton
125
+ label={`Reject memory: ${displayFact}`}
126
+ onClick={() => void act(rejectMemory)}
127
+ disabled={isBusy || isLoading}
128
+ >
129
+ {isRejecting && <SpinnerIcon size={12} />}
130
+ Reject
131
+ </ChipButton>
132
+ </div>
133
+ ) : (
134
+ <p className="stg:text-xs stg:text-muted-foreground" role="status">
135
+ {state === MemoryLifecycleState.lifecycle_state_confirmed
136
+ ? "Confirmed — recalled in your future sessions."
137
+ : "Rejected — not stored."}
138
+ </p>
139
+ )}
140
+
141
+ {actionError && (
142
+ <p className="stg:text-[0.65rem] stg:text-destructive" role="alert">
143
+ {getUserMessage(actionError)}
144
+ </p>
145
+ )}
146
+ </div>
147
+ );
148
+ }
149
+
150
+ function ChipButton({
151
+ label,
152
+ onClick,
153
+ disabled,
154
+ emphasis,
155
+ children,
156
+ }: {
157
+ readonly label: string;
158
+ readonly onClick: () => void;
159
+ readonly disabled?: boolean;
160
+ readonly emphasis?: "primary";
161
+ readonly children: React.ReactNode;
162
+ }) {
163
+ return (
164
+ <button
165
+ type="button"
166
+ aria-label={label}
167
+ onClick={onClick}
168
+ disabled={disabled}
169
+ className={cn(
170
+ "stg:inline-flex stg:items-center stg:gap-1 stg:rounded-md stg:px-2.5 stg:py-1 stg:text-xs stg:font-medium",
171
+ emphasis === "primary"
172
+ ? "stg:bg-primary stg:text-primary-foreground stg:hover:bg-primary-hover"
173
+ : "stg:border stg:border-input stg:bg-background stg:text-foreground stg:hover:bg-accent stg:hover:text-accent-foreground",
174
+ "stg:focus-visible:outline-none stg:focus-visible:ring-2 stg:focus-visible:ring-ring",
175
+ "stg:disabled:pointer-events-none stg:disabled:opacity-50",
176
+ )}
177
+ >
178
+ {children}
179
+ </button>
180
+ );
181
+ }
@@ -0,0 +1,236 @@
1
+ // The in-session consent surface (DD-005 D4): verbatim fact, one-click
2
+ // Confirm/Reject, and lifecycle honesty — the frozen tool result never
3
+ // shows stale action buttons over a record that was already decided,
4
+ // deleted, or unreachable.
5
+
6
+ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
7
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
8
+ import userEvent from "@testing-library/user-event";
9
+ import type { ReactNode } from "react";
10
+ import { create } from "@bufbuild/protobuf";
11
+ import { MemorySchema, type Memory } from "@stigmer/protos/ai/stigmer/agentic/memory/v1/api_pb";
12
+ import { MemoryLifecycleState } from "@stigmer/protos/ai/stigmer/agentic/memory/v1/enum_pb";
13
+ import { StigmerError } from "@stigmer/sdk";
14
+ import { StigmerContext } from "../../context";
15
+ import { FetchCacheContext } from "../../internal/FetchCacheProvider";
16
+ import { MemoryProposalCardBody } from "../MemoryProposalCard";
17
+
18
+ function wrapper(client: unknown) {
19
+ return function Wrapper({ children }: { children: ReactNode }) {
20
+ return (
21
+ <FetchCacheContext.Provider value={null}>
22
+ <StigmerContext.Provider value={client as never}>
23
+ {children}
24
+ </StigmerContext.Provider>
25
+ </FetchCacheContext.Provider>
26
+ );
27
+ };
28
+ }
29
+
30
+ const FACT = "Prefers concise answers with code examples.";
31
+
32
+ function makeMemory(state: MemoryLifecycleState, content = FACT): Memory {
33
+ return create(MemorySchema, {
34
+ apiVersion: "agentic.stigmer.ai/v1",
35
+ kind: "Memory",
36
+ metadata: { id: "mem_1", name: "mem_1", slug: "mem-1", org: "test-org" },
37
+ spec: {
38
+ content,
39
+ provenance: { agentId: "agt_1", sessionId: "ses_1" },
40
+ },
41
+ status: { lifecycleState: state },
42
+ });
43
+ }
44
+
45
+ function renderCard(client: unknown) {
46
+ return render(<MemoryProposalCardBody memoryId="mem_1" fact={FACT} />, {
47
+ wrapper: wrapper(client),
48
+ });
49
+ }
50
+
51
+ /** Waits for the record fetch to settle and the named action to arm. */
52
+ async function waitForAction(name: string): Promise<HTMLElement> {
53
+ let button: HTMLElement | null = null;
54
+ await waitFor(() => {
55
+ button = screen.getByRole("button", { name });
56
+ expect(button.hasAttribute("disabled")).toBe(false);
57
+ });
58
+ return button!;
59
+ }
60
+
61
+ beforeEach(() => vi.clearAllMocks());
62
+ afterEach(cleanup);
63
+
64
+ describe("MemoryProposalCardBody", () => {
65
+ it("renders the verbatim fact with Confirm/Reject while the record is proposed", async () => {
66
+ const client = {
67
+ memory: {
68
+ get: vi.fn().mockResolvedValue(
69
+ makeMemory(MemoryLifecycleState.lifecycle_state_proposed),
70
+ ),
71
+ },
72
+ };
73
+
74
+ renderCard(client);
75
+
76
+ // The exact stored text — never paraphrased (DD-005 D6).
77
+ expect(screen.getByText(FACT)).toBeTruthy();
78
+ await waitForAction(`Confirm memory: ${FACT}`);
79
+ expect(screen.getByRole("button", { name: `Reject memory: ${FACT}` })).toBeTruthy();
80
+ // Provenance renders beside the fact (DD-005 D6: origin builds trust).
81
+ expect(screen.getByText(/Proposed by agent agt_1/)).toBeTruthy();
82
+ });
83
+
84
+ it("confirms in one click and settles into the confirmed state line", async () => {
85
+ const confirm = vi
86
+ .fn()
87
+ .mockResolvedValue(makeMemory(MemoryLifecycleState.lifecycle_state_confirmed));
88
+ const client = {
89
+ memory: {
90
+ get: vi.fn().mockResolvedValue(
91
+ makeMemory(MemoryLifecycleState.lifecycle_state_proposed),
92
+ ),
93
+ confirm,
94
+ },
95
+ };
96
+
97
+ renderCard(client);
98
+ const user = userEvent.setup();
99
+ const confirmButton = await waitForAction(`Confirm memory: ${FACT}`);
100
+
101
+ await user.click(confirmButton);
102
+
103
+ expect(confirm).toHaveBeenCalledWith("mem_1");
104
+ await waitFor(() =>
105
+ expect(
106
+ screen.getByText("Confirmed — recalled in your future sessions."),
107
+ ).toBeTruthy(),
108
+ );
109
+ expect(screen.queryByRole("button")).toBeNull();
110
+ });
111
+
112
+ it("rejects in ONE click — no confirmation dialog (the T04 Cursor lesson)", async () => {
113
+ const reject = vi
114
+ .fn()
115
+ .mockResolvedValue(makeMemory(MemoryLifecycleState.lifecycle_state_rejected));
116
+ const client = {
117
+ memory: {
118
+ get: vi.fn().mockResolvedValue(
119
+ makeMemory(MemoryLifecycleState.lifecycle_state_proposed),
120
+ ),
121
+ reject,
122
+ },
123
+ };
124
+
125
+ renderCard(client);
126
+ const user = userEvent.setup();
127
+ const rejectButton = await waitForAction(`Reject memory: ${FACT}`);
128
+
129
+ await user.click(rejectButton);
130
+
131
+ expect(reject).toHaveBeenCalledWith("mem_1");
132
+ await waitFor(() =>
133
+ expect(screen.getByText("Rejected — not stored.")).toBeTruthy(),
134
+ );
135
+ });
136
+
137
+ it("shows the decided state instead of stale actions when another surface decided it", async () => {
138
+ // The tool result is frozen; the record was confirmed from the
139
+ // memory page (or another device) — the reloaded chip must say so.
140
+ const client = {
141
+ memory: {
142
+ get: vi.fn().mockResolvedValue(
143
+ makeMemory(MemoryLifecycleState.lifecycle_state_confirmed),
144
+ ),
145
+ },
146
+ };
147
+
148
+ renderCard(client);
149
+
150
+ await waitFor(() =>
151
+ expect(
152
+ screen.getByText("Confirmed — recalled in your future sessions."),
153
+ ).toBeTruthy(),
154
+ );
155
+ expect(screen.queryByRole("button")).toBeNull();
156
+ });
157
+
158
+ it("renders the live record's text when the fact was edited before deciding", async () => {
159
+ const edited = "Prefers concise answers, with Go examples.";
160
+ const client = {
161
+ memory: {
162
+ get: vi.fn().mockResolvedValue(
163
+ makeMemory(MemoryLifecycleState.lifecycle_state_proposed, edited),
164
+ ),
165
+ },
166
+ };
167
+
168
+ renderCard(client);
169
+
170
+ await waitFor(() => expect(screen.getByText(edited)).toBeTruthy());
171
+ expect(screen.queryByText(FACT)).toBeNull();
172
+ });
173
+
174
+ it("reads 'no longer stored' for a deleted record — a state, never an error", async () => {
175
+ const client = {
176
+ memory: {
177
+ get: vi
178
+ .fn()
179
+ .mockRejectedValue(new StigmerError("not-found", "memory not found", 5)),
180
+ },
181
+ };
182
+
183
+ renderCard(client);
184
+
185
+ await waitFor(() =>
186
+ expect(screen.getByText("No longer stored.")).toBeTruthy(),
187
+ );
188
+ expect(screen.queryByRole("button")).toBeNull();
189
+ expect(screen.queryByRole("alert")).toBeNull();
190
+ });
191
+
192
+ it("keeps the frozen fact visible when the state fetch fails, pointing at the memory page", async () => {
193
+ const client = {
194
+ memory: {
195
+ get: vi
196
+ .fn()
197
+ .mockRejectedValue(new StigmerError("unavailable", "server down", 14)),
198
+ },
199
+ };
200
+
201
+ renderCard(client);
202
+
203
+ await waitFor(() =>
204
+ expect(screen.getByText(/review it in Settings → Memory/)).toBeTruthy(),
205
+ );
206
+ // The fact is frozen in the tool result — it never disappears.
207
+ expect(screen.getByText(FACT)).toBeTruthy();
208
+ expect(screen.queryByRole("button")).toBeNull();
209
+ });
210
+
211
+ it("surfaces a failed decision inline and keeps the actions available", async () => {
212
+ const client = {
213
+ memory: {
214
+ get: vi.fn().mockResolvedValue(
215
+ makeMemory(MemoryLifecycleState.lifecycle_state_proposed),
216
+ ),
217
+ confirm: vi
218
+ .fn()
219
+ .mockRejectedValue(new StigmerError("unavailable", "server down", 14)),
220
+ },
221
+ };
222
+
223
+ renderCard(client);
224
+ const user = userEvent.setup();
225
+ const confirmButton = await waitForAction(`Confirm memory: ${FACT}`);
226
+
227
+ await user.click(confirmButton);
228
+
229
+ await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());
230
+ expect(
231
+ screen
232
+ .getByRole("button", { name: `Confirm memory: ${FACT}` })
233
+ .hasAttribute("disabled"),
234
+ ).toBe(false);
235
+ });
236
+ });