@stigmer/react 3.12.7 → 3.12.9
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/README.md +1 -3
- 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/conversation/ConversationTimelineView.d.ts +15 -1
- package/conversation/ConversationTimelineView.d.ts.map +1 -1
- package/conversation/ConversationTimelineView.js +3 -2
- package/conversation/ConversationTimelineView.js.map +1 -1
- package/conversation/ConversationsWorkbench.d.ts +14 -1
- package/conversation/ConversationsWorkbench.d.ts.map +1 -1
- package/conversation/ConversationsWorkbench.js +11 -2
- package/conversation/ConversationsWorkbench.js.map +1 -1
- package/execution/MessageThread.d.ts +33 -2
- package/execution/MessageThread.d.ts.map +1 -1
- package/execution/MessageThread.js +44 -6
- package/execution/MessageThread.js.map +1 -1
- package/execution/RecalledMemoriesCard.d.ts +47 -0
- package/execution/RecalledMemoriesCard.d.ts.map +1 -0
- package/execution/RecalledMemoriesCard.js +58 -0
- package/execution/RecalledMemoriesCard.js.map +1 -0
- 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/index.d.ts +2 -0
- package/execution/index.d.ts.map +1 -1
- package/execution/index.js +1 -0
- package/execution/index.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 +6 -6
- package/index.d.ts.map +1 -1
- package/index.js +2 -2
- 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/internal/VirtualizedThread.d.ts +3 -1
- package/internal/VirtualizedThread.d.ts.map +1 -1
- package/internal/VirtualizedThread.js +3 -1
- package/internal/VirtualizedThread.js.map +1 -1
- package/internal/useAutoScroll.d.ts +18 -0
- package/internal/useAutoScroll.d.ts.map +1 -1
- package/internal/useAutoScroll.js +50 -0
- package/internal/useAutoScroll.js.map +1 -1
- 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/settings/MemorySection.d.ts.map +1 -1
- package/settings/MemorySection.js +1 -1
- package/settings/MemorySection.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/conversation/ConversationTimelineView.tsx +17 -1
- package/src/conversation/ConversationsWorkbench.tsx +25 -0
- package/src/conversation/__tests__/ConversationTimelineView.layout.test.tsx +37 -1
- package/src/execution/MessageThread.tsx +75 -3
- package/src/execution/RecalledMemoriesCard.tsx +164 -0
- package/src/execution/ResultView.tsx +6 -0
- package/src/execution/ToolCallItem.tsx +18 -1
- package/src/execution/__tests__/RecalledMemoriesCard.test.tsx +123 -0
- package/src/execution/__tests__/message-thread-recalled-memories.test.tsx +216 -0
- package/src/execution/__tests__/message-thread-scroll-on-send.test.tsx +141 -0
- package/src/execution/__tests__/message-thread-slots.test.tsx +36 -0
- package/src/execution/index.ts +2 -0
- 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 +8 -1
- package/src/internal/MemoryEnabledRow.tsx +92 -0
- package/src/internal/VirtualizedThread.tsx +5 -0
- package/src/internal/__tests__/useAutoScroll.layout.test.tsx +105 -4
- package/src/internal/__tests__/useAutoScroll.test.tsx +41 -1
- package/src/internal/useAutoScroll.ts +54 -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/settings/MemorySection.tsx +4 -2
- package/src/switch/Switch.tsx +4 -0
- package/src/workflow/__tests__/WorkflowExecutionViewer.approvals.test.tsx +26 -7
- package/src/workflow/__tests__/task-presentation.test.ts +49 -12
- package/src/workflow/__tests__/useWorkflowCanvas.dirty.test.tsx +7 -2
- package/src/workflow/__tests__/useWorkflowCanvas.drag.test.tsx +7 -2
- package/src/workflow/__tests__/workflow-thread-scroll-on-send.test.tsx +145 -0
- package/src/workflow/thread/WorkflowTaskThread.tsx +59 -32
- package/src/workflow/thread/task-presentation.ts +52 -7
- 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/workflow/thread/WorkflowTaskThread.d.ts +13 -0
- package/workflow/thread/WorkflowTaskThread.d.ts.map +1 -1
- package/workflow/thread/WorkflowTaskThread.js +33 -25
- package/workflow/thread/WorkflowTaskThread.js.map +1 -1
- package/workflow/thread/task-presentation.d.ts.map +1 -1
- package/workflow/thread/task-presentation.js +50 -7
- package/workflow/thread/task-presentation.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
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)
|
|
@@ -285,6 +286,8 @@ export {
|
|
|
285
286
|
ContextGauge,
|
|
286
287
|
SummarizationBadge,
|
|
287
288
|
SummarizationCard,
|
|
289
|
+
RecalledMemoriesCard,
|
|
290
|
+
resolveInjectedFacts,
|
|
288
291
|
PlanCompletionCard,
|
|
289
292
|
PlanArtifactCard,
|
|
290
293
|
PlanStreamingCard,
|
|
@@ -411,6 +414,7 @@ export type {
|
|
|
411
414
|
ContextGaugeProps,
|
|
412
415
|
SummarizationBadgeProps,
|
|
413
416
|
SummarizationCardProps,
|
|
417
|
+
RecalledMemoriesCardProps,
|
|
414
418
|
PlanCompletionCardProps,
|
|
415
419
|
PlanArtifactCardProps,
|
|
416
420
|
PlanStreamingCardProps,
|
|
@@ -551,7 +555,6 @@ export type {
|
|
|
551
555
|
SessionComposerHandle,
|
|
552
556
|
SessionComposerProps,
|
|
553
557
|
SessionComposerSubmitContext,
|
|
554
|
-
ComposerAutoApproveProps,
|
|
555
558
|
InteractionModePickerProps,
|
|
556
559
|
InteractionModeOption,
|
|
557
560
|
} from "./composer/index.js";
|
|
@@ -866,6 +869,7 @@ export type {
|
|
|
866
869
|
// hooks (confirm/reject/delete/edit), grouping helpers, and the list panel
|
|
867
870
|
export {
|
|
868
871
|
useMemories,
|
|
872
|
+
useMemory,
|
|
869
873
|
useConfirmMemory,
|
|
870
874
|
useRejectMemory,
|
|
871
875
|
useDeleteMemory,
|
|
@@ -873,15 +877,18 @@ export {
|
|
|
873
877
|
groupMemoriesByLifecycle,
|
|
874
878
|
formatMemoryProvenance,
|
|
875
879
|
MemoryListPanel,
|
|
880
|
+
MemoryProposalCardBody,
|
|
876
881
|
} from "./memory/index.js";
|
|
877
882
|
export type {
|
|
878
883
|
UseMemoriesReturn,
|
|
884
|
+
UseMemoryReturn,
|
|
879
885
|
UseConfirmMemoryReturn,
|
|
880
886
|
UseRejectMemoryReturn,
|
|
881
887
|
UseDeleteMemoryReturn,
|
|
882
888
|
UseUpdateMemoryContentReturn,
|
|
883
889
|
MemoryGroups,
|
|
884
890
|
MemoryListPanelProps,
|
|
891
|
+
MemoryProposalCardBodyProps,
|
|
885
892
|
} from "./memory/index.js";
|
|
886
893
|
|
|
887
894
|
// 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
|
+
}
|
|
@@ -25,6 +25,7 @@ import { ApprovalContext, type ApprovalContextValue } from "../execution/Approva
|
|
|
25
25
|
import { FileReviewContext, type FileReviewContextValue } from "../execution/FileReviewContext.js";
|
|
26
26
|
import { DevProfiler, useDomNodeCount } from "./dev/index.js";
|
|
27
27
|
import { JumpToLatestButton } from "./JumpToLatestButton.js";
|
|
28
|
+
import { usePinToLatestOnSignal } from "./useAutoScroll.js";
|
|
28
29
|
import { ApprovalPeekBar } from "./ApprovalPeekBar.js";
|
|
29
30
|
import { ThreadItemWrapper } from "./ThreadItemWrapper.js";
|
|
30
31
|
|
|
@@ -57,6 +58,8 @@ export interface VirtualizedThreadProps {
|
|
|
57
58
|
readonly onRetryExecution?: (message: string) => void;
|
|
58
59
|
readonly onEditMessage?: (text: string) => void;
|
|
59
60
|
readonly slots?: MessageThreadSlots;
|
|
61
|
+
/** Scroll-on-send counter from `MessageThread` (see `usePinToLatestOnSignal`). */
|
|
62
|
+
readonly pinToLatestSignal?: number;
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
// ---------------------------------------------------------------------------
|
|
@@ -127,6 +130,7 @@ export function VirtualizedThread({
|
|
|
127
130
|
onRetryExecution,
|
|
128
131
|
onEditMessage,
|
|
129
132
|
slots,
|
|
133
|
+
pinToLatestSignal,
|
|
130
134
|
}: VirtualizedThreadProps) {
|
|
131
135
|
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
|
132
136
|
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
|
@@ -149,6 +153,7 @@ export function VirtualizedThread({
|
|
|
149
153
|
behavior: "smooth",
|
|
150
154
|
});
|
|
151
155
|
}, []);
|
|
156
|
+
usePinToLatestOnSignal(pinToLatestSignal, jumpToLatest);
|
|
152
157
|
|
|
153
158
|
const renderProps = useMemo(
|
|
154
159
|
() => ({
|
|
@@ -17,7 +17,11 @@
|
|
|
17
17
|
|
|
18
18
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
19
19
|
import { render, cleanup } from "@testing-library/react";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
useAutoScroll,
|
|
22
|
+
usePinToLatestOnSignal,
|
|
23
|
+
type UseAutoScrollReturn,
|
|
24
|
+
} from "../useAutoScroll";
|
|
21
25
|
|
|
22
26
|
const VIEWPORT_PX = 240;
|
|
23
27
|
const ITEM_PX = 48;
|
|
@@ -125,9 +129,11 @@ describe("useAutoScroll under real layout (F-09)", () => {
|
|
|
125
129
|
scroller().scrollTop = 0;
|
|
126
130
|
await settled(() => expect(latest.isFollowing).toBe(false));
|
|
127
131
|
|
|
128
|
-
// Growth while disengaged (an
|
|
129
|
-
//
|
|
130
|
-
//
|
|
132
|
+
// Growth while disengaged (an INCOMING message): the view must NOT
|
|
133
|
+
// move under them. Unchanged by scroll-on-send (stigmer-cloud#267):
|
|
134
|
+
// the reader's OWN send now pins via the surface-driven signal
|
|
135
|
+
// (`usePinToLatestOnSignal`, suite below) — the hook's growth pathway
|
|
136
|
+
// itself still never moves a scrolled-up reader.
|
|
131
137
|
rerender(<AsyncThread items={messages(31)} />);
|
|
132
138
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
133
139
|
expect(scroller().scrollTop).toBe(0);
|
|
@@ -141,3 +147,98 @@ describe("useAutoScroll under real layout (F-09)", () => {
|
|
|
141
147
|
});
|
|
142
148
|
});
|
|
143
149
|
});
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Scroll-on-send: the surface-driven pin signal (stigmer-cloud#267)
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* AsyncThread plus the send-signal seam every thread surface wires — same
|
|
157
|
+
* late-mounting wrapper shape (see the determinism note above: the bottom
|
|
158
|
+
* pin coming from the ResizeObserver pathway is what makes the scroll-up
|
|
159
|
+
* steps race-free).
|
|
160
|
+
*/
|
|
161
|
+
function SignalThread({
|
|
162
|
+
items,
|
|
163
|
+
signal,
|
|
164
|
+
}: {
|
|
165
|
+
readonly items: readonly string[];
|
|
166
|
+
readonly signal: number | undefined;
|
|
167
|
+
}) {
|
|
168
|
+
const hook = useAutoScroll();
|
|
169
|
+
latest = hook;
|
|
170
|
+
usePinToLatestOnSignal(signal, hook.jumpToLatest);
|
|
171
|
+
return (
|
|
172
|
+
<div
|
|
173
|
+
ref={hook.scrollRef}
|
|
174
|
+
data-testid="scroller"
|
|
175
|
+
style={{ height: VIEWPORT_PX, overflowY: "auto" }}
|
|
176
|
+
>
|
|
177
|
+
{items.length === 0 ? (
|
|
178
|
+
<div data-testid="skeleton" style={{ height: 100 }} />
|
|
179
|
+
) : (
|
|
180
|
+
<div ref={hook.contentRef} data-testid="content">
|
|
181
|
+
{items.map((text) => (
|
|
182
|
+
<div key={text} style={{ height: ITEM_PX }}>
|
|
183
|
+
{text}
|
|
184
|
+
</div>
|
|
185
|
+
))}
|
|
186
|
+
</div>
|
|
187
|
+
)}
|
|
188
|
+
<div ref={hook.sentinelRef} aria-hidden="true" />
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
describe("usePinToLatestOnSignal under real layout (stigmer-cloud#267)", () => {
|
|
194
|
+
it("a signal increment pins a scrolled-up reader to the latest content and re-engages follow", async () => {
|
|
195
|
+
const { rerender } = render(<SignalThread items={[]} signal={0} />);
|
|
196
|
+
rerender(<SignalThread items={messages(30)} signal={0} />);
|
|
197
|
+
await settled(() => {
|
|
198
|
+
expect(isPinnedToBottom(scroller())).toBe(true);
|
|
199
|
+
// Follow-STATE quiescence before the reader scrolls (the F-09
|
|
200
|
+
// case's own discipline) — a queued pre-scroll TRUE must not
|
|
201
|
+
// re-arm follow behind the scroll-up.
|
|
202
|
+
expect(latest.isFollowing).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// The reader deliberately scrolls up — follow disengages.
|
|
206
|
+
scroller().scrollTop = 0;
|
|
207
|
+
await settled(() => expect(latest.isFollowing).toBe(false));
|
|
208
|
+
|
|
209
|
+
// Their OWN send: the surface increments the signal. The pin fires
|
|
210
|
+
// AND re-engages follow, so the reply that streams in next stays in
|
|
211
|
+
// view — the whole point of stigmer-cloud#267.
|
|
212
|
+
rerender(<SignalThread items={messages(30)} signal={1} />);
|
|
213
|
+
await settled(() => {
|
|
214
|
+
expect(isPinnedToBottom(scroller())).toBe(true);
|
|
215
|
+
expect(latest.isFollowing).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// And subsequent growth (the streamed reply) keeps following.
|
|
219
|
+
rerender(<SignalThread items={messages(31)} signal={1} />);
|
|
220
|
+
await settled(() => expect(isPinnedToBottom(scroller())).toBe(true));
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("never pins on mount, on an unchanged signal, or on the undefined→number transition (opt-out and prop-appearing are not sends)", async () => {
|
|
224
|
+
// Mount without a signal: no pin beyond the hook's own first-fill.
|
|
225
|
+
const { rerender } = render(<SignalThread items={[]} signal={undefined} />);
|
|
226
|
+
rerender(<SignalThread items={messages(30)} signal={undefined} />);
|
|
227
|
+
await settled(() => {
|
|
228
|
+
expect(isPinnedToBottom(scroller())).toBe(true);
|
|
229
|
+
expect(latest.isFollowing).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
scroller().scrollTop = 0;
|
|
233
|
+
await settled(() => expect(latest.isFollowing).toBe(false));
|
|
234
|
+
|
|
235
|
+
// undefined → number (the surface flips scrollOnSend back on, or the
|
|
236
|
+
// prop gets wired late): a prop appearing is not a send.
|
|
237
|
+
rerender(<SignalThread items={messages(30)} signal={3} />);
|
|
238
|
+
// Unchanged signal on a later render: also not a send.
|
|
239
|
+
rerender(<SignalThread items={messages(30)} signal={3} />);
|
|
240
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
241
|
+
expect(scroller().scrollTop).toBe(0);
|
|
242
|
+
expect(latest.isFollowing).toBe(false);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -121,13 +121,20 @@ afterEach(() => {
|
|
|
121
121
|
* observation-time geometry and can be delivered after the reader has
|
|
122
122
|
* scrolled again (the stale-payload race pinned in the browser suite) —
|
|
123
123
|
* so the entry list itself is deliberately empty here.
|
|
124
|
+
*
|
|
125
|
+
* The far-from-bottom position is 100, not 0, and that is load-bearing:
|
|
126
|
+
* the hook disengages only when the READER moved (scrollTop differs from
|
|
127
|
+
* the hook's own last write — the growth-vs-reader discriminator,
|
|
128
|
+
* stigmer-cloud#267), and in this harness the mount write landed at 0
|
|
129
|
+
* (happy-dom scrollHeight is 0 at mount). A real reader's scroll-up always
|
|
130
|
+
* moves scrollTop off the pin position; 100 simulates exactly that.
|
|
124
131
|
*/
|
|
125
132
|
function fireIO(nearBottom: boolean) {
|
|
126
133
|
const scroller = screen.getByTestId("scroller");
|
|
127
134
|
Object.defineProperty(scroller, "scrollHeight", { value: 1000, configurable: true });
|
|
128
135
|
Object.defineProperty(scroller, "clientHeight", { value: 200, configurable: true });
|
|
129
136
|
// Within the 80px near-bottom margin, or far above it.
|
|
130
|
-
scroller.scrollTop = nearBottom ? 800 :
|
|
137
|
+
scroller.scrollTop = nearBottom ? 800 : 100;
|
|
131
138
|
act(() => {
|
|
132
139
|
ioCallback([], {} as IntersectionObserver);
|
|
133
140
|
});
|
|
@@ -212,6 +219,39 @@ describe("useAutoScroll", () => {
|
|
|
212
219
|
expect(latestResult.isFollowing).toBe(true);
|
|
213
220
|
});
|
|
214
221
|
|
|
222
|
+
it("holds follow when a not-visible delivery measures growth under the hook's own pin — only the READER may disengage (stigmer-cloud#267)", () => {
|
|
223
|
+
render(<Harness />);
|
|
224
|
+
const scroller = screen.getByTestId("scroller");
|
|
225
|
+
Object.defineProperty(scroller, "clientHeight", { value: 200, configurable: true });
|
|
226
|
+
|
|
227
|
+
// The hook pins: jumpToLatest writes scrollTop = scrollHeight and
|
|
228
|
+
// records the write.
|
|
229
|
+
Object.defineProperty(scroller, "scrollHeight", { value: 1000, configurable: true });
|
|
230
|
+
act(() => {
|
|
231
|
+
latestResult.jumpToLatest();
|
|
232
|
+
});
|
|
233
|
+
expect(scroller.scrollTop).toBe(1000);
|
|
234
|
+
|
|
235
|
+
// Content grows UNDER the pin before the RO write runs; a queued IO
|
|
236
|
+
// delivery measures the post-growth geometry: far from the bottom,
|
|
237
|
+
// yet scrollTop still sits exactly on the hook's own write — the
|
|
238
|
+
// reader never moved. Disengaging here would make the imminent RO
|
|
239
|
+
// callback drop its write and strand the thread (the deadlock the
|
|
240
|
+
// browser suite reproduced ~1-in-5 before the discriminator).
|
|
241
|
+
Object.defineProperty(scroller, "scrollHeight", { value: 2000, configurable: true });
|
|
242
|
+
act(() => {
|
|
243
|
+
ioCallback([], {} as IntersectionObserver);
|
|
244
|
+
});
|
|
245
|
+
expect(latestResult.isFollowing).toBe(true);
|
|
246
|
+
|
|
247
|
+
// The READER escapes (scrollTop moves off the pin): disengage holds.
|
|
248
|
+
scroller.scrollTop = 500;
|
|
249
|
+
act(() => {
|
|
250
|
+
ioCallback([], {} as IntersectionObserver);
|
|
251
|
+
});
|
|
252
|
+
expect(latestResult.isFollowing).toBe(false);
|
|
253
|
+
});
|
|
254
|
+
|
|
215
255
|
it("jumpToLatest re-engages follow mode", () => {
|
|
216
256
|
render(<Harness />);
|
|
217
257
|
fireIO(false);
|
|
@@ -69,6 +69,13 @@ export function useAutoScroll(): UseAutoScrollReturn {
|
|
|
69
69
|
const [isFollowing, setIsFollowing] = useState(true);
|
|
70
70
|
const isFollowingRef = useRef(true);
|
|
71
71
|
const rafIdRef = useRef(0);
|
|
72
|
+
// The last scrollTop THIS HOOK wrote (mount pin, rAF growth writes,
|
|
73
|
+
// jumpToLatest). The reader-vs-growth discriminator for the observer
|
|
74
|
+
// callbacks: content growth never moves scrollTop — only the reader
|
|
75
|
+
// does — so "not near bottom" while scrollTop still sits exactly on the
|
|
76
|
+
// system's own last write means content grew under a pin, not that the
|
|
77
|
+
// reader escaped.
|
|
78
|
+
const lastSystemScrollTopRef = useRef<number | null>(null);
|
|
72
79
|
|
|
73
80
|
// Keep ref in sync with state so observer callbacks read the latest
|
|
74
81
|
// value without re-subscribing on every state change.
|
|
@@ -85,6 +92,7 @@ export function useAutoScroll(): UseAutoScrollReturn {
|
|
|
85
92
|
// Establish initial position at the bottom so the first IO
|
|
86
93
|
// callback sees the sentinel as intersecting.
|
|
87
94
|
scroller.scrollTop = scroller.scrollHeight;
|
|
95
|
+
lastSystemScrollTopRef.current = scroller.scrollTop;
|
|
88
96
|
|
|
89
97
|
const io = new IntersectionObserver(
|
|
90
98
|
() => {
|
|
@@ -101,6 +109,18 @@ export function useAutoScroll(): UseAutoScrollReturn {
|
|
|
101
109
|
const visible =
|
|
102
110
|
el.scrollHeight - el.scrollTop - el.clientHeight <=
|
|
103
111
|
NEAR_BOTTOM_MARGIN_PX;
|
|
112
|
+
// The growth-vs-reader discriminator (the write-time guard's
|
|
113
|
+
// mirror, found via the stigmer-cloud#267 pin-on-send suite): a
|
|
114
|
+
// delivery can measure geometry where content ALREADY grew below
|
|
115
|
+
// a system pin but the pin's ResizeObserver write has not run
|
|
116
|
+
// yet. Measured live that reads "not visible" — yet the reader
|
|
117
|
+
// never moved (scrollTop still sits exactly on the system's own
|
|
118
|
+
// last write). Disengaging here would make the imminent RO
|
|
119
|
+
// callback drop its write and strand the thread one row shy of
|
|
120
|
+
// the bottom with follow off. Only the READER may disengage.
|
|
121
|
+
if (!visible && el.scrollTop === lastSystemScrollTopRef.current) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
104
124
|
isFollowingRef.current = visible;
|
|
105
125
|
setIsFollowing(visible);
|
|
106
126
|
},
|
|
@@ -147,6 +167,7 @@ export function useAutoScroll(): UseAutoScrollReturn {
|
|
|
147
167
|
if (!el) return;
|
|
148
168
|
if (scheduledAt !== null && el.scrollTop !== scheduledAt) return;
|
|
149
169
|
el.scrollTop = el.scrollHeight;
|
|
170
|
+
lastSystemScrollTopRef.current = el.scrollTop;
|
|
150
171
|
});
|
|
151
172
|
});
|
|
152
173
|
ro.observe(node);
|
|
@@ -157,6 +178,7 @@ export function useAutoScroll(): UseAutoScrollReturn {
|
|
|
157
178
|
const el = scrollRef.current;
|
|
158
179
|
if (!el) return;
|
|
159
180
|
el.scrollTop = el.scrollHeight;
|
|
181
|
+
lastSystemScrollTopRef.current = el.scrollTop;
|
|
160
182
|
// Eagerly set following — IO callback will confirm when the
|
|
161
183
|
// sentinel becomes visible after the scroll.
|
|
162
184
|
isFollowingRef.current = true;
|
|
@@ -165,3 +187,35 @@ export function useAutoScroll(): UseAutoScrollReturn {
|
|
|
165
187
|
|
|
166
188
|
return { scrollRef, sentinelRef, contentRef, isFollowing, jumpToLatest };
|
|
167
189
|
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Pins the thread to its latest content whenever `signal` changes — the
|
|
193
|
+
* scroll-on-send idiom (stigmer-cloud#267): each surface increments a
|
|
194
|
+
* monotonic counter at its own "the reader sent something" moment (an
|
|
195
|
+
* optimistic message appearing, a conversation reply dispatched, a HITL
|
|
196
|
+
* decision submitted), and the pin re-engages follow mode so the resulting
|
|
197
|
+
* content lands in view even for a reader who had deliberately scrolled up.
|
|
198
|
+
* WhatsApp convention: showing the result of the reader's OWN action is
|
|
199
|
+
* Nielsen #1 system-status feedback — distinct from INCOMING content, which
|
|
200
|
+
* must never move a scrolled-up reader (the F-09 posture, unchanged).
|
|
201
|
+
*
|
|
202
|
+
* <p>No pin fires on mount, on an `undefined` signal (surface opted out or
|
|
203
|
+
* prop not wired), or on the `undefined`→number transition (a prop
|
|
204
|
+
* appearing is not a send).
|
|
205
|
+
*
|
|
206
|
+
* @internal Not part of the public API.
|
|
207
|
+
*/
|
|
208
|
+
export function usePinToLatestOnSignal(
|
|
209
|
+
signal: number | undefined,
|
|
210
|
+
pinToLatest: () => void,
|
|
211
|
+
): void {
|
|
212
|
+
const previousRef = useRef(signal);
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
const previous = previousRef.current;
|
|
215
|
+
previousRef.current = signal;
|
|
216
|
+
if (signal === undefined || previous === undefined || signal === previous) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
pinToLatest();
|
|
220
|
+
}, [signal, pinToLatest]);
|
|
221
|
+
}
|
|
@@ -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
|
+
}
|