@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.
- package/composer/ComposerToolbar.d.ts +1 -19
- package/composer/ComposerToolbar.d.ts.map +1 -1
- package/composer/ComposerToolbar.js +2 -7
- package/composer/ComposerToolbar.js.map +1 -1
- package/composer/SessionComposer.d.ts +0 -10
- package/composer/SessionComposer.d.ts.map +1 -1
- package/composer/SessionComposer.js +2 -2
- package/composer/SessionComposer.js.map +1 -1
- package/composer/index.d.ts +0 -1
- package/composer/index.d.ts.map +1 -1
- package/composer/index.js.map +1 -1
- package/execution/ResultView.d.ts.map +1 -1
- package/execution/ResultView.js +6 -0
- package/execution/ResultView.js.map +1 -1
- package/execution/ToolCallItem.d.ts.map +1 -1
- package/execution/ToolCallItem.js +13 -2
- package/execution/ToolCallItem.js.map +1 -1
- package/execution/tool-categories.d.ts +1 -1
- package/execution/tool-categories.d.ts.map +1 -1
- package/execution/tool-categories.js +6 -0
- package/execution/tool-categories.js.map +1 -1
- package/identity-account/AccountPreferencesPanel.d.ts +2 -1
- package/identity-account/AccountPreferencesPanel.d.ts.map +1 -1
- package/identity-account/AccountPreferencesPanel.js +41 -3
- package/identity-account/AccountPreferencesPanel.js.map +1 -1
- package/identity-account/useAccountExecutionDefaults.d.ts +7 -0
- package/identity-account/useAccountExecutionDefaults.d.ts.map +1 -1
- package/identity-account/useAccountExecutionDefaults.js +5 -2
- package/identity-account/useAccountExecutionDefaults.js.map +1 -1
- package/index.d.ts +4 -4
- package/index.d.ts.map +1 -1
- package/index.js +1 -1
- package/index.js.map +1 -1
- package/internal/MemoryEnabledRow.d.ts +37 -0
- package/internal/MemoryEnabledRow.d.ts.map +1 -0
- package/internal/MemoryEnabledRow.js +25 -0
- package/internal/MemoryEnabledRow.js.map +1 -0
- package/memory/MemoryProposalCard.d.ts +35 -0
- package/memory/MemoryProposalCard.d.ts.map +1 -0
- package/memory/MemoryProposalCard.js +73 -0
- package/memory/MemoryProposalCard.js.map +1 -0
- package/memory/index.d.ts +2 -0
- package/memory/index.d.ts.map +1 -1
- package/memory/index.js +2 -0
- package/memory/index.js.map +1 -1
- package/memory/useMemory.d.ts +39 -0
- package/memory/useMemory.d.ts.map +1 -0
- package/memory/useMemory.js +43 -0
- package/memory/useMemory.js.map +1 -0
- package/organization/OrgPreferencesPanel.d.ts.map +1 -1
- package/organization/OrgPreferencesPanel.js +36 -4
- package/organization/OrgPreferencesPanel.js.map +1 -1
- package/package.json +4 -4
- package/session/AutoApproveIndicator.d.ts +18 -0
- package/session/AutoApproveIndicator.d.ts.map +1 -0
- package/session/AutoApproveIndicator.js +23 -0
- package/session/AutoApproveIndicator.js.map +1 -0
- package/session/NewSessionViewer.d.ts.map +1 -1
- package/session/NewSessionViewer.js +10 -9
- package/session/NewSessionViewer.js.map +1 -1
- package/session/SessionViewer.d.ts +21 -9
- package/session/SessionViewer.d.ts.map +1 -1
- package/session/SessionViewer.js +31 -21
- package/session/SessionViewer.js.map +1 -1
- package/session/facets/SetupTab.d.ts +43 -6
- package/session/facets/SetupTab.d.ts.map +1 -1
- package/session/facets/SetupTab.js +32 -36
- package/session/facets/SetupTab.js.map +1 -1
- package/session/facets/UsageTab.d.ts +2 -1
- package/session/facets/UsageTab.d.ts.map +1 -1
- package/session/facets/UsageTab.js +11 -5
- package/session/facets/UsageTab.js.map +1 -1
- package/session/facets/index.d.ts +1 -1
- package/session/facets/index.d.ts.map +1 -1
- package/session/facets/primitives.d.ts +58 -0
- package/session/facets/primitives.d.ts.map +1 -0
- package/session/facets/primitives.js +59 -0
- package/session/facets/primitives.js.map +1 -0
- package/session/index.d.ts +1 -1
- package/session/index.d.ts.map +1 -1
- package/session/index.js.map +1 -1
- package/session/useNewSessionFlow.d.ts +9 -4
- package/session/useNewSessionFlow.d.ts.map +1 -1
- package/session/useNewSessionFlow.js +20 -9
- package/session/useNewSessionFlow.js.map +1 -1
- package/session/useSessionPageFlow.d.ts +28 -10
- package/session/useSessionPageFlow.d.ts.map +1 -1
- package/session/useSessionPageFlow.js +16 -10
- package/session/useSessionPageFlow.js.map +1 -1
- package/src/attachment/__tests__/prepare-image.test.ts +12 -6
- package/src/composer/ComposerToolbar.tsx +0 -57
- package/src/composer/SessionComposer.tsx +1 -13
- package/src/composer/index.ts +0 -1
- package/src/execution/ResultView.tsx +6 -0
- package/src/execution/ToolCallItem.tsx +18 -1
- package/src/execution/tool-categories.ts +7 -0
- package/src/identity-account/AccountPreferencesPanel.tsx +89 -2
- package/src/identity-account/__tests__/AccountPreferencesPanel.test.tsx +112 -0
- package/src/identity-account/__tests__/useAccountExecutionDefaults.test.tsx +34 -1
- package/src/identity-account/useAccountExecutionDefaults.ts +12 -2
- package/src/index.ts +5 -1
- package/src/internal/MemoryEnabledRow.tsx +92 -0
- package/src/memory/MemoryProposalCard.tsx +181 -0
- package/src/memory/__tests__/MemoryProposalCard.test.tsx +236 -0
- package/src/memory/__tests__/memoryHooks.test.tsx +63 -0
- package/src/memory/index.ts +5 -0
- package/src/memory/useMemory.ts +80 -0
- package/src/organization/OrgPreferencesPanel.tsx +52 -3
- package/src/organization/__tests__/OrgPreferencesPanel.test.tsx +65 -0
- package/src/session/AutoApproveIndicator.tsx +45 -0
- package/src/session/NewSessionViewer.tsx +18 -15
- package/src/session/SessionViewer.tsx +70 -32
- package/src/session/__tests__/NewSessionViewer.test.tsx +32 -0
- package/src/session/__tests__/SessionViewer-autoApprove.test.tsx +244 -0
- package/src/session/__tests__/SessionViewer-transcriptExport.test.tsx +60 -22
- package/src/session/__tests__/useNewSessionFlow.test.tsx +56 -0
- package/src/session/__tests__/useSessionPageFlow.autoApprove.test.tsx +87 -0
- package/src/session/facets/SetupTab.tsx +193 -157
- package/src/session/facets/UsageTab.tsx +38 -38
- package/src/session/facets/index.ts +1 -0
- package/src/session/facets/primitives.tsx +169 -0
- package/src/session/index.ts +1 -0
- package/src/session/useNewSessionFlow.ts +30 -15
- package/src/session/useSessionPageFlow.ts +44 -20
- package/src/switch/Switch.tsx +4 -0
- package/src/workflow/__tests__/useWorkflowCanvas.dirty.test.tsx +7 -2
- package/src/workflow/__tests__/useWorkflowCanvas.drag.test.tsx +7 -2
- package/src/workspace/WorkspaceSurface.tsx +26 -3
- package/styles.css +1 -1
- package/switch/Switch.d.ts +3 -1
- package/switch/Switch.d.ts.map +1 -1
- package/switch/Switch.js +2 -2
- package/switch/Switch.js.map +1 -1
- package/workspace/WorkspaceSurface.js +11 -2
- package/workspace/WorkspaceSurface.js.map +1 -1
- package/src/composer/__tests__/ComposerToolbar-autoApprove.test.tsx +0 -80
- 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't load this proposal'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
|
+
});
|