@stigmer/react 3.12.6 → 3.12.7

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 (149) hide show
  1. package/composer/ComposerToolbar.d.ts +19 -1
  2. package/composer/ComposerToolbar.d.ts.map +1 -1
  3. package/composer/ComposerToolbar.js +7 -2
  4. package/composer/ComposerToolbar.js.map +1 -1
  5. package/composer/SessionComposer.d.ts +10 -0
  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 +1 -0
  10. package/composer/index.d.ts.map +1 -1
  11. package/composer/index.js.map +1 -1
  12. package/execution/MessageThread.d.ts.map +1 -1
  13. package/execution/MessageThread.js +8 -11
  14. package/execution/MessageThread.js.map +1 -1
  15. package/execution/useFileChangeContent.d.ts +2 -6
  16. package/execution/useFileChangeContent.d.ts.map +1 -1
  17. package/execution/useFileChangeContent.js +4 -12
  18. package/execution/useFileChangeContent.js.map +1 -1
  19. package/index.d.ts +6 -3
  20. package/index.d.ts.map +1 -1
  21. package/index.js +5 -1
  22. package/index.js.map +1 -1
  23. package/internal/download.d.ts +8 -0
  24. package/internal/download.d.ts.map +1 -0
  25. package/internal/download.js +25 -0
  26. package/internal/download.js.map +1 -0
  27. package/library/useExportResource.d.ts.map +1 -1
  28. package/library/useExportResource.js +3 -17
  29. package/library/useExportResource.js.map +1 -1
  30. package/memory/MemoryListPanel.d.ts +35 -0
  31. package/memory/MemoryListPanel.d.ts.map +1 -0
  32. package/memory/MemoryListPanel.js +141 -0
  33. package/memory/MemoryListPanel.js.map +1 -0
  34. package/memory/index.d.ts +8 -0
  35. package/memory/index.d.ts.map +1 -0
  36. package/memory/index.js +12 -0
  37. package/memory/index.js.map +1 -0
  38. package/memory/memoryGroups.d.ts +31 -0
  39. package/memory/memoryGroups.d.ts.map +1 -0
  40. package/memory/memoryGroups.js +52 -0
  41. package/memory/memoryGroups.js.map +1 -0
  42. package/memory/useConfirmMemory.d.ts +31 -0
  43. package/memory/useConfirmMemory.d.ts.map +1 -0
  44. package/memory/useConfirmMemory.js +43 -0
  45. package/memory/useConfirmMemory.js.map +1 -0
  46. package/memory/useDeleteMemory.d.ts +30 -0
  47. package/memory/useDeleteMemory.d.ts.map +1 -0
  48. package/memory/useDeleteMemory.js +42 -0
  49. package/memory/useDeleteMemory.js.map +1 -0
  50. package/memory/useMemories.d.ts +33 -0
  51. package/memory/useMemories.d.ts.map +1 -0
  52. package/memory/useMemories.js +42 -0
  53. package/memory/useMemories.js.map +1 -0
  54. package/memory/useRejectMemory.d.ts +34 -0
  55. package/memory/useRejectMemory.d.ts.map +1 -0
  56. package/memory/useRejectMemory.js +46 -0
  57. package/memory/useRejectMemory.js.map +1 -0
  58. package/memory/useUpdateMemoryContent.d.ts +32 -0
  59. package/memory/useUpdateMemoryContent.d.ts.map +1 -0
  60. package/memory/useUpdateMemoryContent.js +48 -0
  61. package/memory/useUpdateMemoryContent.js.map +1 -0
  62. package/package.json +4 -4
  63. package/session/NewSessionViewer.d.ts.map +1 -1
  64. package/session/NewSessionViewer.js +9 -1
  65. package/session/NewSessionViewer.js.map +1 -1
  66. package/session/SessionViewer.d.ts +14 -1
  67. package/session/SessionViewer.d.ts.map +1 -1
  68. package/session/SessionViewer.js +21 -25
  69. package/session/SessionViewer.js.map +1 -1
  70. package/session/TranscriptExportMenu.d.ts +24 -0
  71. package/session/TranscriptExportMenu.d.ts.map +1 -0
  72. package/session/TranscriptExportMenu.js +26 -0
  73. package/session/TranscriptExportMenu.js.map +1 -0
  74. package/session/index.d.ts +4 -0
  75. package/session/index.d.ts.map +1 -1
  76. package/session/index.js +2 -0
  77. package/session/index.js.map +1 -1
  78. package/session/useExportTranscript.d.ts +44 -0
  79. package/session/useExportTranscript.d.ts.map +1 -0
  80. package/session/useExportTranscript.js +109 -0
  81. package/session/useExportTranscript.js.map +1 -0
  82. package/session/useNewSessionFlow.d.ts +11 -0
  83. package/session/useNewSessionFlow.d.ts.map +1 -1
  84. package/session/useNewSessionFlow.js +14 -6
  85. package/session/useNewSessionFlow.js.map +1 -1
  86. package/session/useSessionConversation.d.ts.map +1 -1
  87. package/session/useSessionConversation.js +7 -19
  88. package/session/useSessionConversation.js.map +1 -1
  89. package/session/useSessionExecutions.d.ts +2 -14
  90. package/session/useSessionExecutions.d.ts.map +1 -1
  91. package/session/useSessionExecutions.js +4 -26
  92. package/session/useSessionExecutions.js.map +1 -1
  93. package/session/useSessionPageFlow.d.ts +23 -10
  94. package/session/useSessionPageFlow.d.ts.map +1 -1
  95. package/session/useSessionPageFlow.js +61 -9
  96. package/session/useSessionPageFlow.js.map +1 -1
  97. package/settings/MemorySection.d.ts +12 -0
  98. package/settings/MemorySection.d.ts.map +1 -0
  99. package/settings/MemorySection.js +21 -0
  100. package/settings/MemorySection.js.map +1 -0
  101. package/settings/index.d.ts +1 -0
  102. package/settings/index.d.ts.map +1 -1
  103. package/settings/index.js +1 -0
  104. package/settings/index.js.map +1 -1
  105. package/settings/settings-nav.d.ts.map +1 -1
  106. package/settings/settings-nav.js +2 -1
  107. package/settings/settings-nav.js.map +1 -1
  108. package/src/__tests__/public-api.test.ts +21 -0
  109. package/src/composer/ComposerToolbar.tsx +58 -0
  110. package/src/composer/SessionComposer.tsx +13 -1
  111. package/src/composer/__tests__/ComposerToolbar-autoApprove.test.tsx +80 -0
  112. package/src/composer/index.ts +1 -0
  113. package/src/execution/MessageThread.tsx +8 -11
  114. package/src/execution/useFileChangeContent.ts +5 -12
  115. package/src/index.ts +34 -0
  116. package/src/internal/download.ts +29 -0
  117. package/src/library/useExportResource.ts +3 -19
  118. package/src/memory/MemoryListPanel.tsx +532 -0
  119. package/src/memory/__tests__/MemoryListPanel.test.tsx +259 -0
  120. package/src/memory/__tests__/memoryHooks.test.tsx +220 -0
  121. package/src/memory/index.ts +19 -0
  122. package/src/memory/memoryGroups.ts +64 -0
  123. package/src/memory/useConfirmMemory.ts +62 -0
  124. package/src/memory/useDeleteMemory.ts +61 -0
  125. package/src/memory/useMemories.ts +66 -0
  126. package/src/memory/useRejectMemory.ts +65 -0
  127. package/src/memory/useUpdateMemoryContent.ts +67 -0
  128. package/src/session/NewSessionViewer.tsx +15 -1
  129. package/src/session/SessionViewer.tsx +48 -49
  130. package/src/session/TranscriptExportMenu.tsx +78 -0
  131. package/src/session/__tests__/SessionViewer-autoApproveToggle.test.tsx +178 -0
  132. package/src/session/__tests__/SessionViewer-transcriptExport.test.tsx +151 -0
  133. package/src/session/__tests__/TranscriptExportMenu.test.tsx +97 -0
  134. package/src/session/__tests__/useExportTranscript.test.tsx +187 -0
  135. package/src/session/__tests__/useNewSessionFlow.test.tsx +33 -0
  136. package/src/session/__tests__/useSessionPageFlow.autoApprove.test.tsx +176 -2
  137. package/src/session/index.ts +9 -0
  138. package/src/session/useExportTranscript.ts +171 -0
  139. package/src/session/useNewSessionFlow.ts +28 -7
  140. package/src/session/useSessionConversation.ts +10 -16
  141. package/src/session/useSessionExecutions.ts +5 -26
  142. package/src/session/useSessionPageFlow.ts +84 -21
  143. package/src/settings/MemorySection.tsx +45 -0
  144. package/src/settings/index.ts +1 -0
  145. package/src/settings/settings-nav.ts +2 -0
  146. package/src/usage/useExportCSV.ts +2 -14
  147. package/usage/useExportCSV.d.ts.map +1 -1
  148. package/usage/useExportCSV.js +2 -13
  149. package/usage/useExportCSV.js.map +1 -1
@@ -0,0 +1,532 @@
1
+ "use client";
2
+
3
+ import { useCallback, useState } from "react";
4
+ import { cn } from "@stigmer/theme";
5
+ import { getUserMessage } from "@stigmer/sdk";
6
+ import { timestampDate } from "@bufbuild/protobuf/wkt";
7
+ import type { Memory } from "@stigmer/protos/ai/stigmer/agentic/memory/v1/api_pb";
8
+ import { MemoryLifecycleState } from "@stigmer/protos/ai/stigmer/agentic/memory/v1/enum_pb";
9
+ import { formatRelativeTime } from "../activity/format-relative-time.js";
10
+ import { SpinnerIcon } from "../internal/SpinnerIcon.js";
11
+ import { formatMemoryProvenance, groupMemoriesByLifecycle } from "./memoryGroups.js";
12
+ import { useConfirmMemory } from "./useConfirmMemory.js";
13
+ import { useDeleteMemory } from "./useDeleteMemory.js";
14
+ import { useMemories } from "./useMemories.js";
15
+ import { useRejectMemory } from "./useRejectMemory.js";
16
+ import { useUpdateMemoryContent } from "./useUpdateMemoryContent.js";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Public API
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /** Props for {@link MemoryListPanel}. */
23
+ export interface MemoryListPanelProps {
24
+ /** Organization whose memories to show. */
25
+ readonly org: string;
26
+ /** Additional CSS class names for the root container. */
27
+ readonly className?: string;
28
+ /**
29
+ * Reference instant for relative timestamps. Defaults to the live
30
+ * clock. **Deterministic hosts (documentation embeds, video export)
31
+ * must pass a frozen instant.**
32
+ */
33
+ readonly now?: Date;
34
+ }
35
+
36
+ /**
37
+ * Lists everything the platform remembers about the caller in an
38
+ * organization, grouped pending-proposals-first (DD-005 D4): proposals
39
+ * awaiting a decision, then confirmed facts, then rejected proposals
40
+ * kept for audit.
41
+ *
42
+ * Every fact is shown VERBATIM — the exact stored text is what future
43
+ * prompts inject, byte for byte, so the review surface never paraphrases
44
+ * (DD-005 D6). Provenance renders beside each agent-proposed fact.
45
+ * Actions per state: confirm/reject/delete on proposals (reject is
46
+ * one-click — no confirmation dialog), edit/delete on confirmed facts,
47
+ * delete on rejected ones. Delete asks inline; it works in any state.
48
+ *
49
+ * All visual properties flow through `--stgm-*` design tokens.
50
+ *
51
+ * @example
52
+ * ```tsx
53
+ * <MemoryListPanel org="acme" />
54
+ * ```
55
+ */
56
+ export function MemoryListPanel({ org, className, now }: MemoryListPanelProps) {
57
+ const { memories, isLoading, error, refetch } = useMemories(org);
58
+ // At most one row is mid-interaction at a time — the ApiKeyListPanel
59
+ // single-confirmation posture, extended with an editing mode.
60
+ const [rowState, setRowState] = useState<
61
+ | { mode: "idle" }
62
+ | { mode: "confirming-delete"; id: string }
63
+ | { mode: "editing"; id: string }
64
+ >({ mode: "idle" });
65
+
66
+ const resetRow = useCallback(() => setRowState({ mode: "idle" }), []);
67
+ const handleChanged = useCallback(() => {
68
+ setRowState({ mode: "idle" });
69
+ refetch();
70
+ }, [refetch]);
71
+
72
+ if (isLoading) {
73
+ return (
74
+ <div
75
+ className={cn("stg:space-y-2", className)}
76
+ aria-busy="true"
77
+ aria-label="Loading memories"
78
+ >
79
+ {Array.from({ length: 2 }, (_, i) => (
80
+ <div
81
+ key={i}
82
+ className="stg:bg-muted-subtle stg:h-14 stg:animate-pulse stg:rounded-lg"
83
+ />
84
+ ))}
85
+ </div>
86
+ );
87
+ }
88
+
89
+ if (error) {
90
+ return (
91
+ <p className={cn("stg:text-destructive stg:text-xs", className)} role="alert">
92
+ {getUserMessage(error)}
93
+ </p>
94
+ );
95
+ }
96
+
97
+ if (memories.length === 0) {
98
+ return (
99
+ <p
100
+ className={cn(
101
+ "stg:text-muted-foreground stg:py-4 stg:text-center stg:text-xs",
102
+ className,
103
+ )}
104
+ >
105
+ Nothing remembered yet. When an agent proposes a fact worth
106
+ keeping, it appears here for your decision.
107
+ </p>
108
+ );
109
+ }
110
+
111
+ const groups = groupMemoriesByLifecycle(memories);
112
+
113
+ return (
114
+ <div className={cn("stg:space-y-6", className)}>
115
+ {groups.proposed.length > 0 && (
116
+ <MemoryGroup
117
+ heading="Pending proposals"
118
+ description="Facts agents proposed to remember. Nothing is stored in your sessions until you confirm it."
119
+ memories={groups.proposed}
120
+ rowState={rowState}
121
+ setRowState={setRowState}
122
+ onChanged={handleChanged}
123
+ onCancel={resetRow}
124
+ now={now}
125
+ />
126
+ )}
127
+ {groups.confirmed.length > 0 && (
128
+ <MemoryGroup
129
+ heading="Remembered"
130
+ description="Confirmed facts, shared with agents in your future sessions."
131
+ memories={groups.confirmed}
132
+ rowState={rowState}
133
+ setRowState={setRowState}
134
+ onChanged={handleChanged}
135
+ onCancel={resetRow}
136
+ now={now}
137
+ />
138
+ )}
139
+ {groups.rejected.length > 0 && (
140
+ <MemoryGroup
141
+ heading="Rejected"
142
+ description="Proposals you declined, kept for reference. Delete them to clear the record."
143
+ memories={groups.rejected}
144
+ rowState={rowState}
145
+ setRowState={setRowState}
146
+ onChanged={handleChanged}
147
+ onCancel={resetRow}
148
+ now={now}
149
+ />
150
+ )}
151
+ </div>
152
+ );
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Group + row (internal)
157
+ // ---------------------------------------------------------------------------
158
+
159
+ type RowState =
160
+ | { mode: "idle" }
161
+ | { mode: "confirming-delete"; id: string }
162
+ | { mode: "editing"; id: string };
163
+
164
+ function MemoryGroup({
165
+ heading,
166
+ description,
167
+ memories,
168
+ rowState,
169
+ setRowState,
170
+ onChanged,
171
+ onCancel,
172
+ now,
173
+ }: {
174
+ heading: string;
175
+ description: string;
176
+ memories: readonly Memory[];
177
+ rowState: RowState;
178
+ setRowState: (state: RowState) => void;
179
+ onChanged: () => void;
180
+ onCancel: () => void;
181
+ now?: Date;
182
+ }) {
183
+ return (
184
+ <div>
185
+ <h3 className="stg:text-foreground stg:text-xs stg:font-semibold">
186
+ {heading}
187
+ </h3>
188
+ <p className="stg:text-muted-foreground stg:mb-2 stg:text-xs">
189
+ {description}
190
+ </p>
191
+ <div className="stg:space-y-2" role="list" aria-label={heading}>
192
+ {memories.map((memory) => {
193
+ const id = memory.metadata?.id ?? "";
194
+ return (
195
+ <MemoryRow
196
+ key={id}
197
+ memory={memory}
198
+ now={now}
199
+ mode={
200
+ rowState.mode !== "idle" && rowState.id === id
201
+ ? rowState.mode
202
+ : "idle"
203
+ }
204
+ onStartDelete={() => setRowState({ mode: "confirming-delete", id })}
205
+ onStartEdit={() => setRowState({ mode: "editing", id })}
206
+ onCancel={onCancel}
207
+ onChanged={onChanged}
208
+ />
209
+ );
210
+ })}
211
+ </div>
212
+ </div>
213
+ );
214
+ }
215
+
216
+ function MemoryRow({
217
+ memory,
218
+ now,
219
+ mode,
220
+ onStartDelete,
221
+ onStartEdit,
222
+ onCancel,
223
+ onChanged,
224
+ }: {
225
+ memory: Memory;
226
+ now?: Date;
227
+ mode: "idle" | "confirming-delete" | "editing";
228
+ onStartDelete: () => void;
229
+ onStartEdit: () => void;
230
+ onCancel: () => void;
231
+ onChanged: () => void;
232
+ }) {
233
+ const id = memory.metadata?.id ?? "";
234
+ const content = memory.spec?.content ?? "";
235
+ const state = memory.status?.lifecycleState;
236
+ const provenance = formatMemoryProvenance(memory);
237
+ const stateChangedAt = memory.status?.stateChangedAt;
238
+
239
+ const { confirmMemory, isConfirming, error: confirmError } = useConfirmMemory();
240
+ const { rejectMemory, isRejecting, error: rejectError } = useRejectMemory();
241
+ const { deleteMemory, isDeleting, error: deleteError } = useDeleteMemory();
242
+
243
+ const actionError = confirmError ?? rejectError ?? deleteError;
244
+ const isBusy = isConfirming || isRejecting || isDeleting;
245
+
246
+ const act = useCallback(
247
+ async (action: (id: string) => Promise<Memory>) => {
248
+ try {
249
+ await action(id);
250
+ onChanged();
251
+ } catch {
252
+ // error state is surfaced via the hooks
253
+ }
254
+ },
255
+ [id, onChanged],
256
+ );
257
+
258
+ if (mode === "editing") {
259
+ return (
260
+ <MemoryEditRow memory={memory} onCancel={onCancel} onSaved={onChanged} />
261
+ );
262
+ }
263
+
264
+ if (mode === "confirming-delete") {
265
+ return (
266
+ <div
267
+ role="listitem"
268
+ className="stg:flex stg:items-center stg:justify-between stg:gap-3 stg:rounded-lg stg:border stg:border-destructive/30 stg:bg-destructive-subtle stg:px-3 stg:py-2.5"
269
+ >
270
+ <div className="stg:min-w-0 stg:flex-1">
271
+ <p className="stg:text-xs stg:text-foreground">
272
+ Forget this permanently? <q className="stg:italic">{content}</q>
273
+ </p>
274
+ {deleteError && (
275
+ <p className="stg:mt-0.5 stg:text-[0.65rem] stg:text-destructive">
276
+ {getUserMessage(deleteError)}
277
+ </p>
278
+ )}
279
+ </div>
280
+ <div className="stg:flex stg:shrink-0 stg:items-center stg:gap-1.5">
281
+ <button
282
+ type="button"
283
+ onClick={() => void act(deleteMemory)}
284
+ disabled={isDeleting}
285
+ className={cn(
286
+ "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",
287
+ "stg:bg-destructive stg:text-destructive-foreground stg:hover:bg-destructive-hover",
288
+ "stg:disabled:pointer-events-none stg:disabled:opacity-50",
289
+ )}
290
+ >
291
+ {isDeleting && <SpinnerIcon size={12} />}
292
+ Delete
293
+ </button>
294
+ <button
295
+ type="button"
296
+ onClick={onCancel}
297
+ disabled={isDeleting}
298
+ className={cn(
299
+ "stg:rounded-md stg:px-2.5 stg:py-1 stg:text-xs",
300
+ "stg:text-muted-foreground stg:hover:text-foreground stg:hover:bg-accent-hover",
301
+ "stg:disabled:pointer-events-none stg:disabled:opacity-50",
302
+ )}
303
+ >
304
+ Cancel
305
+ </button>
306
+ </div>
307
+ </div>
308
+ );
309
+ }
310
+
311
+ return (
312
+ <div
313
+ role="listitem"
314
+ className="stg:rounded-lg stg:border stg:border-border-muted stg:px-3 stg:py-2.5 stg:hover:border-border stg:transition-colors"
315
+ >
316
+ <div className="stg:flex stg:items-start stg:justify-between stg:gap-3">
317
+ <div className="stg:min-w-0 stg:flex-1">
318
+ {/* The exact stored text, verbatim — what is confirmed is what
319
+ future prompts inject, byte for byte (DD-005 D6). */}
320
+ <p className="stg:text-sm stg:text-foreground stg:whitespace-pre-wrap">
321
+ {content}
322
+ </p>
323
+ <p className="stg:mt-1 stg:text-xs stg:text-muted-foreground">
324
+ {provenance ? `${provenance} · ` : ""}
325
+ {formatStateStamp(state, stateChangedAt ? timestampDate(stateChangedAt) : undefined, now)}
326
+ </p>
327
+ {actionError && (
328
+ <p className="stg:mt-0.5 stg:text-[0.65rem] stg:text-destructive" role="alert">
329
+ {getUserMessage(actionError)}
330
+ </p>
331
+ )}
332
+ </div>
333
+
334
+ <div className="stg:flex stg:shrink-0 stg:items-center stg:gap-1.5">
335
+ {state === MemoryLifecycleState.lifecycle_state_confirmed && (
336
+ <RowButton label={`Edit memory: ${content}`} onClick={onStartEdit} disabled={isBusy}>
337
+ Edit
338
+ </RowButton>
339
+ )}
340
+ {isProposed(state) && (
341
+ <>
342
+ <RowButton
343
+ label={`Confirm memory: ${content}`}
344
+ onClick={() => void act(confirmMemory)}
345
+ disabled={isBusy}
346
+ emphasis="primary"
347
+ >
348
+ {isConfirming && <SpinnerIcon size={12} />}
349
+ Confirm
350
+ </RowButton>
351
+ <RowButton
352
+ label={`Reject memory: ${content}`}
353
+ onClick={() => void act(rejectMemory)}
354
+ disabled={isBusy}
355
+ >
356
+ {isRejecting && <SpinnerIcon size={12} />}
357
+ Reject
358
+ </RowButton>
359
+ </>
360
+ )}
361
+ <button
362
+ type="button"
363
+ onClick={onStartDelete}
364
+ disabled={isBusy}
365
+ aria-label={`Delete memory: ${content}`}
366
+ className={cn(
367
+ "stg:shrink-0 stg:rounded stg:p-1",
368
+ "stg:text-muted-foreground stg:hover:text-destructive stg:hover:bg-destructive-subtle",
369
+ "stg:transition-colors stg:disabled:pointer-events-none stg:disabled:opacity-50",
370
+ )}
371
+ >
372
+ <TrashIcon />
373
+ </button>
374
+ </div>
375
+ </div>
376
+ </div>
377
+ );
378
+ }
379
+
380
+ // MemoryEditRow edits the fact text in place — the one field the
381
+ // subject owns. Saving maps the loaded record through the generated
382
+ // update mapper (wipe-safe) and overrides only content.
383
+ function MemoryEditRow({
384
+ memory,
385
+ onCancel,
386
+ onSaved,
387
+ }: {
388
+ memory: Memory;
389
+ onCancel: () => void;
390
+ onSaved: () => void;
391
+ }) {
392
+ const [draft, setDraft] = useState(memory.spec?.content ?? "");
393
+ const { updateContent, isUpdating, error } = useUpdateMemoryContent();
394
+
395
+ const trimmed = draft.trim();
396
+ const canSave = trimmed.length > 0 && trimmed.length <= 500 && !isUpdating;
397
+
398
+ const handleSave = async () => {
399
+ try {
400
+ await updateContent(memory, trimmed);
401
+ onSaved();
402
+ } catch {
403
+ // error state is surfaced via the hook
404
+ }
405
+ };
406
+
407
+ return (
408
+ <div
409
+ role="listitem"
410
+ className="stg:rounded-lg stg:border stg:border-border stg:px-3 stg:py-2.5"
411
+ >
412
+ <textarea
413
+ value={draft}
414
+ onChange={(e) => setDraft(e.target.value)}
415
+ rows={2}
416
+ maxLength={500}
417
+ aria-label="Edit memory text"
418
+ className={cn(
419
+ "stg:w-full stg:resize-y stg:rounded-md stg:border stg:border-input stg:bg-background stg:px-2 stg:py-1.5 stg:text-sm stg:text-foreground",
420
+ "stg:focus-visible:outline-none stg:focus-visible:ring-2 stg:focus-visible:ring-ring",
421
+ )}
422
+ />
423
+ <div className="stg:mt-1.5 stg:flex stg:items-center stg:justify-between">
424
+ <span className="stg:text-[0.65rem] stg:text-muted-foreground">
425
+ {trimmed.length}/500
426
+ </span>
427
+ <div className="stg:flex stg:items-center stg:gap-1.5">
428
+ <RowButton
429
+ label="Save memory text"
430
+ onClick={() => void handleSave()}
431
+ disabled={!canSave}
432
+ emphasis="primary"
433
+ >
434
+ {isUpdating && <SpinnerIcon size={12} />}
435
+ Save
436
+ </RowButton>
437
+ <RowButton label="Cancel editing" onClick={onCancel} disabled={isUpdating}>
438
+ Cancel
439
+ </RowButton>
440
+ </div>
441
+ </div>
442
+ {error && (
443
+ <p className="stg:mt-1 stg:text-[0.65rem] stg:text-destructive" role="alert">
444
+ {getUserMessage(error)}
445
+ </p>
446
+ )}
447
+ </div>
448
+ );
449
+ }
450
+
451
+ // ---------------------------------------------------------------------------
452
+ // Presentation helpers (internal)
453
+ // ---------------------------------------------------------------------------
454
+
455
+ function isProposed(state: MemoryLifecycleState | undefined): boolean {
456
+ // The zero unspecified state should not exist (create stamps
457
+ // proposed); offering the decision actions is the safe rendering.
458
+ return (
459
+ state === undefined ||
460
+ state === MemoryLifecycleState.lifecycle_state_unspecified ||
461
+ state === MemoryLifecycleState.lifecycle_state_proposed
462
+ );
463
+ }
464
+
465
+ function formatStateStamp(
466
+ state: MemoryLifecycleState | undefined,
467
+ changedAt: Date | undefined,
468
+ now: Date | undefined,
469
+ ): string {
470
+ const verb =
471
+ state === MemoryLifecycleState.lifecycle_state_confirmed
472
+ ? "Confirmed"
473
+ : state === MemoryLifecycleState.lifecycle_state_rejected
474
+ ? "Rejected"
475
+ : "Proposed";
476
+ if (!changedAt) return verb;
477
+ return `${verb} ${formatRelativeTime(changedAt, now)}`;
478
+ }
479
+
480
+ function RowButton({
481
+ label,
482
+ onClick,
483
+ disabled,
484
+ emphasis,
485
+ children,
486
+ }: {
487
+ readonly label: string;
488
+ readonly onClick: () => void;
489
+ readonly disabled?: boolean;
490
+ readonly emphasis?: "primary";
491
+ readonly children: React.ReactNode;
492
+ }) {
493
+ return (
494
+ <button
495
+ type="button"
496
+ aria-label={label}
497
+ onClick={onClick}
498
+ disabled={disabled}
499
+ className={cn(
500
+ "stg:inline-flex stg:items-center stg:gap-1 stg:rounded-md stg:px-2 stg:py-1 stg:text-xs stg:font-medium",
501
+ emphasis === "primary"
502
+ ? "stg:bg-primary stg:text-primary-foreground stg:hover:bg-primary-hover"
503
+ : "stg:border stg:border-input stg:bg-background stg:text-foreground stg:hover:bg-accent stg:hover:text-accent-foreground",
504
+ "stg:focus-visible:outline-none stg:focus-visible:ring-2 stg:focus-visible:ring-ring",
505
+ "stg:disabled:pointer-events-none stg:disabled:opacity-50",
506
+ )}
507
+ >
508
+ {children}
509
+ </button>
510
+ );
511
+ }
512
+
513
+ function TrashIcon() {
514
+ return (
515
+ <svg
516
+ width="14"
517
+ height="14"
518
+ viewBox="0 0 16 16"
519
+ fill="none"
520
+ stroke="currentColor"
521
+ strokeWidth="1.5"
522
+ strokeLinecap="round"
523
+ strokeLinejoin="round"
524
+ aria-hidden="true"
525
+ >
526
+ <path d="M2.5 4h11M5.5 4V2.5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1V4" />
527
+ <path d="M12.5 4v9a1 1 0 0 1-1 1h-7a1 1 0 0 1-1-1V4" />
528
+ <line x1="6.5" y1="7" x2="6.5" y2="11" />
529
+ <line x1="9.5" y1="7" x2="9.5" y2="11" />
530
+ </svg>
531
+ );
532
+ }