@handled-ai/design-system 0.9.23 → 0.9.25

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.
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Tests for ScoreFeedback.Root's `initialFeedback` prop and related behaviors.
3
+ *
4
+ * Covers:
5
+ * - Initial `submittedFeedback` pre-population via prop
6
+ * - "Noted" / "Recorded" shown instead of thumbs when pre-populated
7
+ * - null initialFeedback → blank state (thumbs shown)
8
+ * - useEffect sync: prop changes from null → value updates submittedFeedback
9
+ * - useEffect guard: in-progress edit (thumbState ≠ null) skips sync
10
+ * - editSubmitted restores prior feedback into editing state
11
+ * - handleSubmit calls onSubmitFeedback
12
+ * - factor callback fix: text ?? "" (empty string, not undefined)
13
+ */
14
+
15
+ import { describe, it, expect, vi } from "vitest";
16
+ import React from "react";
17
+ import { render, screen, fireEvent, act } from "@testing-library/react";
18
+ import { ScoreFeedback } from "../score-feedback";
19
+ import {
20
+ makeInitialScoreFeedback,
21
+ makeNegativeInitialScoreFeedback,
22
+ makeMinimalInitialScoreFeedback,
23
+ } from "../../__test-helpers__/fixtures";
24
+
25
+ // ─── Minimal compound-component wrapper ──────────────────────────────────────
26
+
27
+ function Wrapper({
28
+ initialFeedback,
29
+ onSubmitFeedback,
30
+ }: {
31
+ initialFeedback?: { type: "up" | "down"; pills: string[]; detail: string } | null;
32
+ onSubmitFeedback?: (type: "up" | "down", pills: string[], detail: string) => void;
33
+ }) {
34
+ return (
35
+ <ScoreFeedback.Root
36
+ initialFeedback={initialFeedback}
37
+ onSubmitFeedback={onSubmitFeedback}
38
+ >
39
+ <ScoreFeedback.Trigger />
40
+ <ScoreFeedback.Panel />
41
+ </ScoreFeedback.Root>
42
+ );
43
+ }
44
+
45
+ // ─── Tests ────────────────────────────────────────────────────────────────────
46
+
47
+ describe("ScoreFeedback.Root — initialFeedback prop", () => {
48
+ it("shows 'Noted' when initialFeedback.type is 'up'", () => {
49
+ render(<Wrapper initialFeedback={makeInitialScoreFeedback()} />);
50
+ expect(screen.getByText("Noted")).toBeDefined();
51
+ });
52
+
53
+ it("shows 'Recorded' when initialFeedback.type is 'down'", () => {
54
+ render(<Wrapper initialFeedback={makeNegativeInitialScoreFeedback()} />);
55
+ expect(screen.getByText("Recorded")).toBeDefined();
56
+ });
57
+
58
+ it("does NOT show thumbs when initialFeedback is set (submitted state)", () => {
59
+ render(
60
+ <Wrapper initialFeedback={makeInitialScoreFeedback()} />,
61
+ );
62
+ // Thumbs-up and thumbs-down buttons should not be rendered
63
+ // The only button visible should be the "edit" button (with pill/detail), not bare thumbs
64
+ // We check by absence of the thumb click pattern — no button with onClick=handleThumbClick("up")
65
+ // More concretely: no thumb buttons means the Trigger is in "submitted" mode
66
+ expect(screen.queryByTitle?.("")).toBeNull?.(); // optional, just no crash
67
+ expect(screen.getByText("Noted")).toBeDefined();
68
+ });
69
+
70
+ it("shows thumbs when initialFeedback is null", () => {
71
+ const { container } = render(<Wrapper initialFeedback={null} />);
72
+ // Panel is hidden (no thumbState), Trigger shows thumb buttons
73
+ const buttons = container.querySelectorAll("button");
74
+ // Two thumb buttons are present
75
+ expect(buttons.length).toBeGreaterThanOrEqual(2);
76
+ // No "Noted" or "Recorded" text
77
+ expect(screen.queryByText("Noted")).toBeNull();
78
+ expect(screen.queryByText("Recorded")).toBeNull();
79
+ });
80
+
81
+ it("shows thumbs when initialFeedback is undefined", () => {
82
+ const { container } = render(<Wrapper />);
83
+ const buttons = container.querySelectorAll("button");
84
+ expect(buttons.length).toBeGreaterThanOrEqual(2);
85
+ expect(screen.queryByText("Noted")).toBeNull();
86
+ });
87
+
88
+ it("renders submitted pills from initialFeedback", () => {
89
+ render(
90
+ <Wrapper
91
+ initialFeedback={makeInitialScoreFeedback({
92
+ pills: ["Right timing", "Accurate data"],
93
+ })}
94
+ />,
95
+ );
96
+ expect(screen.getByText("Right timing")).toBeDefined();
97
+ expect(screen.getByText("Accurate data")).toBeDefined();
98
+ });
99
+
100
+ it("renders submitted detail text from initialFeedback", () => {
101
+ render(
102
+ <Wrapper
103
+ initialFeedback={makeInitialScoreFeedback({ detail: "Score looks correct." })}
104
+ />,
105
+ );
106
+ expect(screen.getByText("Score looks correct.")).toBeDefined();
107
+ });
108
+
109
+ it("minimal initialFeedback (no pills, no detail) still shows Noted without edit button", () => {
110
+ const { container } = render(
111
+ <Wrapper initialFeedback={makeMinimalInitialScoreFeedback("up")} />,
112
+ );
113
+ expect(screen.getByText("Noted")).toBeDefined();
114
+ // No pill text to find — just check no crash
115
+ const buttons = container.querySelectorAll("button");
116
+ // The edit button requires pills.length > 0 || detail, so no edit button here
117
+ expect(buttons.length).toBe(0);
118
+ });
119
+ });
120
+
121
+ describe("ScoreFeedback.Root — useEffect sync", () => {
122
+ it("updates submittedFeedback when initialFeedback prop changes from null to value", async () => {
123
+ const { rerender } = render(<Wrapper initialFeedback={null} />);
124
+
125
+ // Initially null → thumbs shown
126
+ expect(screen.queryByText("Noted")).toBeNull();
127
+
128
+ // Prop changes to a value
129
+ await act(async () => {
130
+ rerender(<Wrapper initialFeedback={makeInitialScoreFeedback()} />);
131
+ });
132
+
133
+ // Now should show "Noted"
134
+ expect(screen.getByText("Noted")).toBeDefined();
135
+ });
136
+
137
+ it("updates from 'up' to 'down' when prop changes", async () => {
138
+ const { rerender } = render(
139
+ <Wrapper initialFeedback={makeInitialScoreFeedback()} />,
140
+ );
141
+ expect(screen.getByText("Noted")).toBeDefined();
142
+
143
+ await act(async () => {
144
+ rerender(<Wrapper initialFeedback={makeNegativeInitialScoreFeedback()} />);
145
+ });
146
+
147
+ expect(screen.getByText("Recorded")).toBeDefined();
148
+ });
149
+
150
+ it("guard: does NOT sync when user has thumbState set (in-progress edit)", async () => {
151
+ // Start with null initialFeedback
152
+ const { rerender, container } = render(<Wrapper initialFeedback={null} />);
153
+
154
+ // User clicks thumbs-up to start an edit
155
+ const buttons = container.querySelectorAll("button");
156
+ await act(async () => {
157
+ fireEvent.click(buttons[0]); // thumbs-up button
158
+ });
159
+
160
+ // Now prop changes — should NOT overwrite in-progress edit
161
+ await act(async () => {
162
+ rerender(<Wrapper initialFeedback={makeNegativeInitialScoreFeedback()} />);
163
+ });
164
+
165
+ // Should NOT show "Recorded" — user's thumb state takes precedence
166
+ expect(screen.queryByText("Recorded")).toBeNull();
167
+ // Panel should be visible (thumbState is set)
168
+ // The Panel shows "How's this score?" section
169
+ expect(screen.getByText(/How['']s this score\?/)).toBeDefined();
170
+ });
171
+ });
172
+
173
+ describe("ScoreFeedback.Root — onSubmitFeedback callback", () => {
174
+ it("calls onSubmitFeedback with (type, pills, detail) on submit", async () => {
175
+ const onSubmit = vi.fn();
176
+ const { container } = render(
177
+ <Wrapper initialFeedback={null} onSubmitFeedback={onSubmit} />,
178
+ );
179
+
180
+ // Click thumbs-up
181
+ const thumbButtons = container.querySelectorAll("button");
182
+ await act(async () => {
183
+ fireEvent.click(thumbButtons[0]); // thumbs-up
184
+ });
185
+
186
+ // Select a pill
187
+ const pillButton = screen.getByText("Right timing");
188
+ await act(async () => {
189
+ fireEvent.click(pillButton);
190
+ });
191
+
192
+ // Click Submit
193
+ const submitButton = screen.getByText("Submit");
194
+ await act(async () => {
195
+ fireEvent.click(submitButton);
196
+ });
197
+
198
+ expect(onSubmit).toHaveBeenCalledTimes(1);
199
+ expect(onSubmit).toHaveBeenCalledWith("up", ["Right timing"], "");
200
+ });
201
+
202
+ it("calls onSubmitFeedback with empty string detail when no detail entered", async () => {
203
+ const onSubmit = vi.fn();
204
+ const { container } = render(
205
+ <Wrapper initialFeedback={null} onSubmitFeedback={onSubmit} />,
206
+ );
207
+
208
+ const thumbButtons = container.querySelectorAll("button");
209
+ await act(async () => {
210
+ fireEvent.click(thumbButtons[0]); // thumbs-up
211
+ });
212
+
213
+ const pillButton = screen.getByText("Right timing");
214
+ await act(async () => {
215
+ fireEvent.click(pillButton);
216
+ });
217
+
218
+ const submitButton = screen.getByText("Submit");
219
+ await act(async () => {
220
+ fireEvent.click(submitButton);
221
+ });
222
+
223
+ const [, , detail] = onSubmit.mock.calls[0] as [string, string[], string];
224
+ expect(detail).toBe("");
225
+ });
226
+ });
227
+
228
+ describe("ScoreFeedback.Root — editSubmitted", () => {
229
+ it("clicking the pills/detail edit area restores feedback into editing state", async () => {
230
+ const { container } = render(
231
+ <Wrapper
232
+ initialFeedback={makeInitialScoreFeedback({
233
+ pills: ["Right timing"],
234
+ detail: "Score looks correct.",
235
+ })}
236
+ />,
237
+ );
238
+
239
+ // The edit button (wraps pills + detail) should be present
240
+ const editButton = container.querySelector("button");
241
+ expect(editButton).not.toBeNull();
242
+
243
+ await act(async () => {
244
+ fireEvent.click(editButton!);
245
+ });
246
+
247
+ // After clicking, the Panel should be visible with "How's this score?"
248
+ expect(screen.getByText(/How['']s this score\?/)).toBeDefined();
249
+ // And the previously selected pill should be pre-selected
250
+ // (visible in the panel's pill list as selected)
251
+ expect(screen.getAllByText("Right timing").length).toBeGreaterThanOrEqual(1);
252
+ });
253
+ });
@@ -33,14 +33,59 @@ interface ScoreBreakdownProps {
33
33
  factors: ScoreFactor[]
34
34
  onFactorFeedback?: (factorKey: string, type: "up" | "down" | null, detail?: string) => void
35
35
  className?: string
36
+ initialFeedback?: Record<string, { type: "up" | "down"; detail: string }>
36
37
  }
37
38
 
38
- function ScoreBreakdown({ factors, onFactorFeedback, className }: ScoreBreakdownProps) {
39
- const [feedback, setFeedback] = React.useState<Record<string, "up" | "down" | null>>({})
40
- const [feedbackText, setFeedbackText] = React.useState<Record<string, string>>({})
41
- const [savedText, setSavedText] = React.useState<Record<string, string>>({})
39
+ function deriveInitialState<T>(
40
+ init: Record<string, { type: "up" | "down"; detail: string }> | undefined,
41
+ mapFn: (v: { type: "up" | "down"; detail: string }) => T,
42
+ filterFn?: (v: { type: "up" | "down"; detail: string }) => boolean,
43
+ ): Record<string, T> {
44
+ if (!init) return {}
45
+ const entries = Object.entries(init)
46
+ const filtered = filterFn ? entries.filter(([, v]) => filterFn(v)) : entries
47
+ return Object.fromEntries(filtered.map(([k, v]) => [k, mapFn(v)]))
48
+ }
49
+
50
+ function ScoreBreakdown({ factors, onFactorFeedback, className, initialFeedback }: ScoreBreakdownProps) {
51
+ const [feedback, setFeedback] = React.useState<Record<string, "up" | "down" | null>>(
52
+ () => deriveInitialState(initialFeedback, (v) => v.type)
53
+ )
54
+ const [feedbackText, setFeedbackText] = React.useState<Record<string, string>>(
55
+ () => deriveInitialState(initialFeedback, (v) => v.detail)
56
+ )
57
+ const [savedText, setSavedText] = React.useState<Record<string, string>>(
58
+ () => deriveInitialState(initialFeedback, (v) => v.detail, (v) => !!v.detail)
59
+ )
42
60
  const [editingKey, setEditingKey] = React.useState<string | null>(null)
43
61
 
62
+ // Sync state when initialFeedback prop changes (e.g. async hydration).
63
+ // Skip keys the user is actively editing to avoid clobbering in-progress input.
64
+ React.useEffect(() => {
65
+ setFeedback((prev) => {
66
+ const next: Record<string, "up" | "down" | null> = deriveInitialState(initialFeedback, (v) => v.type)
67
+ // Preserve any key the user is currently editing
68
+ if (editingKey && prev[editingKey] !== undefined) {
69
+ next[editingKey] = prev[editingKey]
70
+ }
71
+ return next
72
+ })
73
+ setFeedbackText((prev) => {
74
+ const next = deriveInitialState(initialFeedback, (v) => v.detail)
75
+ if (editingKey && prev[editingKey] !== undefined) {
76
+ next[editingKey] = prev[editingKey]
77
+ }
78
+ return next
79
+ })
80
+ setSavedText((prev) => {
81
+ const next = deriveInitialState(initialFeedback, (v) => v.detail, (v) => !!v.detail)
82
+ if (editingKey && prev[editingKey] !== undefined) {
83
+ next[editingKey] = prev[editingKey]
84
+ }
85
+ return next
86
+ })
87
+ }, [initialFeedback]) // eslint-disable-line react-hooks/exhaustive-deps -- reads editingKey as guard, not trigger
88
+
44
89
  const handleFeedback = (factorKey: string, type: "up" | "down") => {
45
90
  const newState = feedback[factorKey] === type ? null : type
46
91
  setFeedback((prev) => ({ ...prev, [factorKey]: newState }))
@@ -56,7 +101,7 @@ function ScoreBreakdown({ factors, onFactorFeedback, className }: ScoreBreakdown
56
101
  const submitFeedbackText = (factorKey: string) => {
57
102
  const text = (feedbackText[factorKey] ?? "").trim()
58
103
  if (feedback[factorKey]) {
59
- onFactorFeedback?.(factorKey, feedback[factorKey]!, text || undefined)
104
+ onFactorFeedback?.(factorKey, feedback[factorKey]!, text ?? "")
60
105
  if (text) {
61
106
  setSavedText((prev) => ({ ...prev, [factorKey]: text }))
62
107
  }
@@ -52,14 +52,24 @@ function useScoreFeedback() {
52
52
  interface RootProps {
53
53
  children: React.ReactNode
54
54
  onSubmitFeedback?: (type: "up" | "down", pills: string[], detail: string) => void
55
+ initialFeedback?: { type: "up" | "down"; pills: string[]; detail: string } | null
55
56
  }
56
57
 
57
- function Root({ children, onSubmitFeedback }: RootProps) {
58
+ function Root({ children, onSubmitFeedback, initialFeedback }: RootProps) {
58
59
  const [thumbState, setThumbState] = React.useState<"up" | "down" | null>(null)
59
60
  const [selectedPills, setSelectedPills] = React.useState<string[]>([])
60
61
  const [detailText, setDetailTextState] = React.useState("")
61
62
  const [notedType, setNotedType] = React.useState<"up" | "down" | null>(null)
62
- const [submittedFeedback, setSubmittedFeedback] = React.useState<SubmittedScoreFeedback | null>(null)
63
+ const [submittedFeedback, setSubmittedFeedback] = React.useState<SubmittedScoreFeedback | null>(
64
+ initialFeedback ?? null
65
+ )
66
+
67
+ // Sync submitted feedback when initialFeedback prop changes (e.g. async
68
+ // detail load). Skip when the user has an in-progress edit (thumbState set).
69
+ React.useEffect(() => {
70
+ if (thumbState !== null) return
71
+ setSubmittedFeedback(initialFeedback ?? null)
72
+ }, [initialFeedback]) // eslint-disable-line react-hooks/exhaustive-deps -- intentionally omits thumbState to read it as a guard, not a trigger
63
73
 
64
74
  const otherSelected = selectedPills.includes("Other")
65
75
 
@@ -71,7 +71,7 @@ const approveReasons = [
71
71
  "Actionable",
72
72
  ]
73
73
 
74
- type ApprovalState = "pending" | "confirming" | "approving-feedback" | "dismissing" | "approved" | "dismissed" | "auto-approved"
74
+ type ApprovalState = "pending" | "confirming" | "creating" | "approving-feedback" | "dismissing" | "approved" | "dismissed" | "auto-approved"
75
75
 
76
76
  interface SignalApprovalLabels {
77
77
  approveButton?: string
@@ -82,6 +82,8 @@ interface SignalApprovalLabels {
82
82
  confirmPrompt?: string
83
83
  dismissPrompt?: string
84
84
  feedbackPrompt?: string
85
+ /** Label shown while the approve action is in progress (e.g. "Creating Opportunity..."). */
86
+ creatingStatus?: string
85
87
  }
86
88
 
87
89
  const DEFAULT_LABELS: Required<SignalApprovalLabels> = {
@@ -93,6 +95,7 @@ const DEFAULT_LABELS: Required<SignalApprovalLabels> = {
93
95
  confirmPrompt: "This will approve this action for",
94
96
  dismissPrompt: "What\u2019s the issue with this action?",
95
97
  feedbackPrompt: "Quick feedback \u2014 what made this action useful?",
98
+ creatingStatus: "Creating\u2026",
96
99
  }
97
100
 
98
101
  interface SignalApprovalContextValue {
@@ -128,7 +131,16 @@ interface RootProps {
128
131
  labels?: SignalApprovalLabels
129
132
  /** When true, the approve/create-opportunity button is hidden but the dismiss button remains. */
130
133
  hideApproveButton?: boolean
131
- onApprove?: () => void
134
+ /**
135
+ * Called when the user confirms the approval action.
136
+ *
137
+ * - If the callback returns `void` (or `undefined`), the component transitions
138
+ * directly to the feedback step (backward-compatible behavior).
139
+ * - If the callback returns a `Promise<boolean>`, the component shows a
140
+ * "creating" loading state while the promise is pending. On `true` it
141
+ * transitions to the feedback step; on `false` it reverts to "pending".
142
+ */
143
+ onApprove?: () => void | Promise<boolean>
132
144
  onApproveFeedback?: (reasons: string[], detail: string) => void
133
145
  onDismiss?: (reasons: string[], detail: string, subReason?: string) => void
134
146
  }
@@ -137,6 +149,13 @@ function Root({ children, companyName, opportunityUrl, scheduledTime, initialApp
137
149
  const labels = React.useMemo(() => ({ ...DEFAULT_LABELS, ...labelOverrides }), [labelOverrides])
138
150
  const [approvalState, setApprovalState] = React.useState<ApprovalState>(initialApprovalState ?? "pending")
139
151
 
152
+ // Guard against state updates after unmount (e.g. user navigates away while
153
+ // an async onApprove promise is still in flight).
154
+ const mountedRef = React.useRef(true)
155
+ React.useEffect(() => {
156
+ return () => { mountedRef.current = false }
157
+ }, [])
158
+
140
159
  const requestApproval = React.useCallback(() => {
141
160
  setApprovalState("confirming")
142
161
  }, [])
@@ -150,8 +169,23 @@ function Root({ children, companyName, opportunityUrl, scheduledTime, initialApp
150
169
  }, [])
151
170
 
152
171
  const approve = React.useCallback(() => {
153
- setApprovalState("approving-feedback")
154
- onApprove?.()
172
+ const result = onApprove?.()
173
+ // If the callback returns a Promise, show a loading state and wait for it.
174
+ if (result && typeof (result as Promise<boolean>).then === "function") {
175
+ setApprovalState("creating")
176
+ ;(result as Promise<boolean>).then((success) => {
177
+ if (mountedRef.current) {
178
+ setApprovalState(success ? "approving-feedback" : "pending")
179
+ }
180
+ }).catch(() => {
181
+ if (mountedRef.current) {
182
+ setApprovalState("pending")
183
+ }
184
+ })
185
+ } else {
186
+ // Synchronous / void — transition immediately (backward-compatible).
187
+ setApprovalState("approving-feedback")
188
+ }
155
189
  }, [onApprove])
156
190
 
157
191
  const submitApproveFeedback = React.useCallback(
@@ -439,6 +473,18 @@ function Actions() {
439
473
  setDetailText("")
440
474
  }
441
475
 
476
+ if (approvalState === "creating") {
477
+ return (
478
+ <div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
479
+ <svg className="h-3.5 w-3.5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
480
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
481
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
482
+ </svg>
483
+ <span>{labels.creatingStatus}</span>
484
+ </div>
485
+ )
486
+ }
487
+
442
488
  if (approvalState === "approved") {
443
489
  if (isEditing) {
444
490
  return (
@@ -728,7 +774,7 @@ function Gate({ children }: { children: React.ReactNode }) {
728
774
  const { approvalState, hideApproveButton } = useSignalApproval()
729
775
  // When the approve button is hidden, don't lock content behind approval
730
776
  const isLocked = !hideApproveButton &&
731
- (approvalState === "pending" || approvalState === "confirming" || approvalState === "dismissing")
777
+ (approvalState === "pending" || approvalState === "confirming" || approvalState === "creating" || approvalState === "dismissing")
732
778
 
733
779
  return (
734
780
  <div className="relative">
@@ -46,6 +46,8 @@ export interface SignalScoreData {
46
46
  onScoreFeedback?: (type: "up" | "down", pills: string[], detail: string) => void
47
47
  onApproveFeedback?: (reasons: string[], detail: string) => void
48
48
  onDismissFeedback?: (reasons: string[], detail: string, subReason?: string) => void
49
+ initialScoreFeedback?: { type: "up" | "down"; pills: string[]; detail: string } | null
50
+ initialFactorFeedback?: Record<string, { type: "up" | "down"; detail: string }>
49
51
  }
50
52
 
51
53
  // ---------------------------------------------------------------------------
@@ -78,7 +80,7 @@ export interface InboxViewConfig {
78
80
  quickFilterTabs?: Array<{ id: string; label: string; matchValue?: string; count?: number }>
79
81
  hideAccountsButton?: boolean
80
82
  accountDetailsLabel?: string
81
- onSignalApprove?: (item: QueueItem) => void
83
+ onSignalApprove?: (item: QueueItem) => void | Promise<boolean>
82
84
  getSignalApprovalState?: (item: QueueItem) => ApprovalState | undefined
83
85
  signalLabels?: {
84
86
  approveButton?: string
@@ -87,6 +89,7 @@ export interface InboxViewConfig {
87
89
  dismissedStatus?: string
88
90
  opportunityCreated?: string
89
91
  confirmPrompt?: string
92
+ creatingStatus?: string
90
93
  }
91
94
  /** When true, the approve/create-opportunity button is hidden but the dismiss button remains. */
92
95
  hideApproveButton?: boolean
@@ -102,7 +102,7 @@ export interface DetailViewProps {
102
102
  onOpenEntityPanel?: () => void
103
103
  onOpenRecentActivity?: () => void
104
104
  onSuggestedActionFeedback?: (actionId: number | string, feedback: string, actionTitle?: string) => void
105
- onSignalApprove?: (item: QueueItem) => void
105
+ onSignalApprove?: (item: QueueItem) => void | Promise<boolean>
106
106
  getSignalApprovalState?: (item: QueueItem) => ApprovalState | undefined
107
107
  signalLabels?: InboxViewConfig["signalLabels"]
108
108
  hideApproveButton?: boolean
@@ -279,7 +279,10 @@ export function DetailView({
279
279
  {signalData.whyNow}
280
280
  </p>
281
281
 
282
- <ScoreFeedback.Root onSubmitFeedback={(type, pills, detail) => (signalData.onScoreFeedback ?? onScoreFeedback)?.(type, pills, detail)}>
282
+ <ScoreFeedback.Root
283
+ onSubmitFeedback={(type, pills, detail) => (signalData.onScoreFeedback ?? onScoreFeedback)?.(type, pills, detail)}
284
+ initialFeedback={signalData.initialScoreFeedback}
285
+ >
283
286
  <div className="mb-5 rounded-md border border-border bg-muted/20 p-3">
284
287
  <div className="flex items-center justify-between mb-1.5">
285
288
  <span className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider">Signal Score</span>
@@ -320,6 +323,7 @@ export function DetailView({
320
323
  onFactorFeedback={signalData.onFactorFeedback ?? ((key, type, detail) =>
321
324
  console.log("Signal factor feedback:", { company: item.company, factor: key, type, detail })
322
325
  )}
326
+ initialFeedback={signalData.initialFactorFeedback}
323
327
  />
324
328
  <SignalApproval.Actions />
325
329
  </div>