@handled-ai/design-system 0.18.5 → 0.18.6
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/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/feedback-primitives.d.ts +41 -2
- package/dist/components/feedback-primitives.js +241 -6
- package/dist/components/feedback-primitives.js.map +1 -1
- package/dist/components/pill.d.ts +1 -1
- package/dist/components/score-why-chips.d.ts +1 -1
- package/dist/components/score-why-chips.js +26 -5
- package/dist/components/score-why-chips.js.map +1 -1
- package/dist/components/signal-priority-popover.d.ts +1 -1
- package/dist/components/signal-priority-popover.js +32 -6
- package/dist/components/signal-priority-popover.js.map +1 -1
- package/dist/components/tabs.d.ts +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/prototype/index.d.ts +1 -1
- package/dist/prototype/prototype-accounts-view.d.ts +1 -1
- package/dist/prototype/prototype-admin-view.d.ts +1 -1
- package/dist/prototype/prototype-config.d.ts +1 -1
- package/dist/prototype/prototype-inbox-view.d.ts +1 -1
- package/dist/prototype/prototype-inbox-view.js +4 -1
- package/dist/prototype/prototype-inbox-view.js.map +1 -1
- package/dist/prototype/prototype-insights-view.d.ts +1 -1
- package/dist/prototype/prototype-shell.d.ts +1 -1
- package/dist/{signal-priority-popover-DQ_VuHac.d.ts → signal-priority-popover-DWaAMhPI.d.ts} +26 -2
- package/package.json +1 -1
- package/src/components/__tests__/wit-636-feedback-states.test.tsx +546 -0
- package/src/components/feedback-primitives.tsx +333 -26
- package/src/components/score-why-chips.tsx +28 -2
- package/src/components/signal-priority-popover.tsx +44 -4
- package/src/index.ts +2 -2
- package/src/prototype/prototype-config.ts +11 -1
- package/src/prototype/prototype-inbox-view.tsx +3 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from "react"
|
|
4
|
-
import { ThumbsUp, ThumbsDown } from "lucide-react"
|
|
4
|
+
import { ThumbsUp, ThumbsDown, Check, Pencil } from "lucide-react"
|
|
5
5
|
import { cn } from "../lib/utils"
|
|
6
6
|
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
@@ -25,6 +25,19 @@ export interface FeedbackSubmitData {
|
|
|
25
25
|
detail: string
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Persisted feedback data from a previous submission, used to hydrate the
|
|
30
|
+
* footer into its "already submitted" visual state.
|
|
31
|
+
*/
|
|
32
|
+
export interface PersistedFeedbackData {
|
|
33
|
+
sentiment: "positive" | "negative"
|
|
34
|
+
reasonTop?: string
|
|
35
|
+
reasonSub?: string
|
|
36
|
+
pills?: string[]
|
|
37
|
+
detail?: string
|
|
38
|
+
ownershipLabel: "Your feedback" | "Team feedback"
|
|
39
|
+
}
|
|
40
|
+
|
|
28
41
|
/**
|
|
29
42
|
* Defines a tier-1 chip that may have tier-2 sub-chips.
|
|
30
43
|
*/
|
|
@@ -185,6 +198,13 @@ export interface FeedbackFooterProps {
|
|
|
185
198
|
negativeChips?: FeedbackChipTree[]
|
|
186
199
|
positiveChips?: string[]
|
|
187
200
|
className?: string
|
|
201
|
+
/** Pre-existing feedback to hydrate from (e.g. after page reload). */
|
|
202
|
+
initialFeedback?: PersistedFeedbackData | null
|
|
203
|
+
/** Label shown in the transient confirmation pill after submit. */
|
|
204
|
+
submittedLabel?: string
|
|
205
|
+
/** Stable key for syncing initialFeedback into local state. When this
|
|
206
|
+
* changes, the component resets to the new initialFeedback value. */
|
|
207
|
+
feedbackKey?: string
|
|
188
208
|
}
|
|
189
209
|
|
|
190
210
|
const SENTIMENT_BUTTON_ACTIVE: Record<"positive" | "negative", string> = {
|
|
@@ -205,6 +225,9 @@ export function FeedbackFooter({
|
|
|
205
225
|
negativeChips = [],
|
|
206
226
|
positiveChips = [],
|
|
207
227
|
className,
|
|
228
|
+
initialFeedback,
|
|
229
|
+
submittedLabel = "Saved",
|
|
230
|
+
feedbackKey,
|
|
208
231
|
}: FeedbackFooterProps) {
|
|
209
232
|
const [expanded, setExpanded] = React.useState(false)
|
|
210
233
|
const [selectedTier1, setSelectedTier1] = React.useState<string | null>(null)
|
|
@@ -214,6 +237,48 @@ export function FeedbackFooter({
|
|
|
214
237
|
const [activeTreeIndex, setActiveTreeIndex] = React.useState<number | null>(
|
|
215
238
|
null,
|
|
216
239
|
)
|
|
240
|
+
/** Transient "Saved" confirmation — shown after successful submit. */
|
|
241
|
+
const [submitted, setSubmitted] = React.useState(false)
|
|
242
|
+
/** Persisted feedback shown as a clickable indicator (survives reload). */
|
|
243
|
+
const [persisted, setPersisted] = React.useState<PersistedFeedbackData | null>(
|
|
244
|
+
initialFeedback ?? null,
|
|
245
|
+
)
|
|
246
|
+
/** Tracks whether the user is actively editing (ref to guard against prop overwrites without triggering re-syncs). */
|
|
247
|
+
const isEditingRef = React.useRef(false)
|
|
248
|
+
/** Track the last synced feedbackKey to detect key changes. */
|
|
249
|
+
const lastKeyRef = React.useRef<string | undefined>(feedbackKey)
|
|
250
|
+
|
|
251
|
+
/** Helper to update the editing ref. */
|
|
252
|
+
const setIsEditing = React.useCallback((value: boolean) => {
|
|
253
|
+
isEditingRef.current = value
|
|
254
|
+
}, [])
|
|
255
|
+
|
|
256
|
+
// Sync initialFeedback into local state via useEffect keyed on feedbackKey.
|
|
257
|
+
// When feedbackKey changes, reset to new target. Preserve active edits
|
|
258
|
+
// when feedbackKey stays the same.
|
|
259
|
+
React.useEffect(() => {
|
|
260
|
+
const keyChanged = feedbackKey !== lastKeyRef.current
|
|
261
|
+
lastKeyRef.current = feedbackKey
|
|
262
|
+
|
|
263
|
+
if (keyChanged) {
|
|
264
|
+
// Key changed — full reset to new target
|
|
265
|
+
setPersisted(initialFeedback ?? null)
|
|
266
|
+
setSubmitted(false)
|
|
267
|
+
setExpanded(false)
|
|
268
|
+
isEditingRef.current = false
|
|
269
|
+
if (initialFeedback) {
|
|
270
|
+
onFeedbackChange(initialFeedback.sentiment)
|
|
271
|
+
} else {
|
|
272
|
+
onFeedbackChange(null)
|
|
273
|
+
}
|
|
274
|
+
} else if (!isEditingRef.current) {
|
|
275
|
+
// Same key, not actively editing — safe to sync
|
|
276
|
+
setPersisted(initialFeedback ?? null)
|
|
277
|
+
if (initialFeedback) {
|
|
278
|
+
onFeedbackChange(initialFeedback.sentiment)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}, [initialFeedback, feedbackKey, onFeedbackChange])
|
|
217
282
|
|
|
218
283
|
// Reset state when feedback collapses
|
|
219
284
|
const resetState = React.useCallback(() => {
|
|
@@ -223,7 +288,8 @@ export function FeedbackFooter({
|
|
|
223
288
|
setAdditionalPills([])
|
|
224
289
|
setDetailText("")
|
|
225
290
|
setActiveTreeIndex(null)
|
|
226
|
-
|
|
291
|
+
setIsEditing(false)
|
|
292
|
+
}, [setIsEditing])
|
|
227
293
|
|
|
228
294
|
const handleSentimentClick = React.useCallback(
|
|
229
295
|
(sentiment: "positive" | "negative") => {
|
|
@@ -231,10 +297,26 @@ export function FeedbackFooter({
|
|
|
231
297
|
// Reset chip state when switching sentiment, then expand
|
|
232
298
|
resetState()
|
|
233
299
|
setExpanded(true)
|
|
300
|
+
setSubmitted(false)
|
|
301
|
+
setPersisted(null)
|
|
302
|
+
setIsEditing(true)
|
|
234
303
|
},
|
|
235
|
-
[onFeedbackChange, resetState],
|
|
304
|
+
[onFeedbackChange, resetState, setIsEditing],
|
|
236
305
|
)
|
|
237
306
|
|
|
307
|
+
/** Open the persisted indicator for editing. */
|
|
308
|
+
const handlePersistedClick = React.useCallback(() => {
|
|
309
|
+
if (!persisted) return
|
|
310
|
+
onFeedbackChange(persisted.sentiment)
|
|
311
|
+
setSelectedTier1(persisted.reasonTop ?? null)
|
|
312
|
+
setSelectedTier2(persisted.reasonSub ?? null)
|
|
313
|
+
setAdditionalPills(persisted.pills ?? [])
|
|
314
|
+
setDetailText(persisted.detail ?? "")
|
|
315
|
+
setExpanded(true)
|
|
316
|
+
setSubmitted(false)
|
|
317
|
+
setIsEditing(true)
|
|
318
|
+
}, [persisted, onFeedbackChange, setIsEditing])
|
|
319
|
+
|
|
238
320
|
const handleTier1Toggle = React.useCallback(
|
|
239
321
|
(chipLabel: string) => {
|
|
240
322
|
if (selectedTier1 === chipLabel) {
|
|
@@ -295,6 +377,9 @@ export function FeedbackFooter({
|
|
|
295
377
|
pills: additionalPills,
|
|
296
378
|
detail: detailText,
|
|
297
379
|
})
|
|
380
|
+
// Show transient "Saved" confirmation
|
|
381
|
+
setSubmitted(true)
|
|
382
|
+
// Collapse expansion but keep sentiment visible
|
|
298
383
|
resetState()
|
|
299
384
|
}, [
|
|
300
385
|
feedback,
|
|
@@ -323,38 +408,75 @@ export function FeedbackFooter({
|
|
|
323
408
|
const activeTree =
|
|
324
409
|
activeTreeIndex !== null ? negativeChips[activeTreeIndex] : null
|
|
325
410
|
|
|
411
|
+
// Determine if we should show the persisted indicator instead of bare buttons
|
|
412
|
+
const showPersistedIndicator = persisted && !expanded && !submitted
|
|
413
|
+
|
|
326
414
|
return (
|
|
327
415
|
<div className={cn("space-y-3", className)}>
|
|
328
416
|
{/* Sentiment buttons + meta text bar */}
|
|
329
417
|
<div className="flex items-center justify-between">
|
|
330
|
-
|
|
418
|
+
{showPersistedIndicator ? (
|
|
419
|
+
/* Persisted feedback indicator — clickable to reopen editor */
|
|
331
420
|
<button
|
|
332
421
|
type="button"
|
|
333
|
-
onClick={
|
|
334
|
-
className=
|
|
335
|
-
|
|
336
|
-
feedback === "positive"
|
|
337
|
-
? SENTIMENT_BUTTON_ACTIVE.positive
|
|
338
|
-
: SENTIMENT_BUTTON_IDLE,
|
|
339
|
-
)}
|
|
422
|
+
onClick={handlePersistedClick}
|
|
423
|
+
className="group flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
|
424
|
+
data-testid="persisted-feedback-indicator"
|
|
340
425
|
>
|
|
341
|
-
<
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
onClick={() => handleSentimentClick("negative")}
|
|
347
|
-
className={cn(
|
|
348
|
-
"flex gap-1 items-center text-[11px] font-medium rounded-md px-2 py-1 transition-colors",
|
|
349
|
-
feedback === "negative"
|
|
350
|
-
? SENTIMENT_BUTTON_ACTIVE.negative
|
|
351
|
-
: SENTIMENT_BUTTON_IDLE,
|
|
426
|
+
<span className="font-medium">{persisted.ownershipLabel}:</span>
|
|
427
|
+
{persisted.sentiment === "positive" ? (
|
|
428
|
+
<ThumbsUp className="h-[11px] w-[11px]" />
|
|
429
|
+
) : (
|
|
430
|
+
<ThumbsDown className="h-[11px] w-[11px]" />
|
|
352
431
|
)}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
432
|
+
{persisted.detail && (
|
|
433
|
+
<span className="max-w-[200px] truncate text-muted-foreground/70">
|
|
434
|
+
{persisted.detail}
|
|
435
|
+
</span>
|
|
436
|
+
)}
|
|
437
|
+
<Pencil className="h-[9px] w-[9px] opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
356
438
|
</button>
|
|
357
|
-
|
|
439
|
+
) : (
|
|
440
|
+
<div className="flex items-center gap-3">
|
|
441
|
+
<button
|
|
442
|
+
type="button"
|
|
443
|
+
onClick={() => handleSentimentClick("positive")}
|
|
444
|
+
className={cn(
|
|
445
|
+
"flex gap-1 items-center text-[11px] font-medium rounded-md px-2 py-1 transition-colors",
|
|
446
|
+
feedback === "positive"
|
|
447
|
+
? SENTIMENT_BUTTON_ACTIVE.positive
|
|
448
|
+
: SENTIMENT_BUTTON_IDLE,
|
|
449
|
+
)}
|
|
450
|
+
>
|
|
451
|
+
<ThumbsUp className="h-[11px] w-[11px]" />
|
|
452
|
+
Helpful
|
|
453
|
+
</button>
|
|
454
|
+
<button
|
|
455
|
+
type="button"
|
|
456
|
+
onClick={() => handleSentimentClick("negative")}
|
|
457
|
+
className={cn(
|
|
458
|
+
"flex gap-1 items-center text-[11px] font-medium rounded-md px-2 py-1 transition-colors",
|
|
459
|
+
feedback === "negative"
|
|
460
|
+
? SENTIMENT_BUTTON_ACTIVE.negative
|
|
461
|
+
: SENTIMENT_BUTTON_IDLE,
|
|
462
|
+
)}
|
|
463
|
+
>
|
|
464
|
+
<ThumbsDown className="h-[11px] w-[11px]" />
|
|
465
|
+
Not helpful
|
|
466
|
+
</button>
|
|
467
|
+
{/* Transient "Saved" confirmation pill */}
|
|
468
|
+
{submitted && feedback && (
|
|
469
|
+
<span
|
|
470
|
+
className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-600"
|
|
471
|
+
role="status"
|
|
472
|
+
data-testid="feedback-submitted-pill"
|
|
473
|
+
>
|
|
474
|
+
<Check className="h-[11px] w-[11px]" />
|
|
475
|
+
{submittedLabel}
|
|
476
|
+
</span>
|
|
477
|
+
)}
|
|
478
|
+
</div>
|
|
479
|
+
)}
|
|
358
480
|
{metaText && (
|
|
359
481
|
<span className="text-[11px] text-muted-foreground">{metaText}</span>
|
|
360
482
|
)}
|
|
@@ -422,3 +544,188 @@ export function FeedbackFooter({
|
|
|
422
544
|
</div>
|
|
423
545
|
)
|
|
424
546
|
}
|
|
547
|
+
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
// InlineFeedbackControl — shared thumb+detail inline feedback widget
|
|
550
|
+
// ---------------------------------------------------------------------------
|
|
551
|
+
|
|
552
|
+
export interface InlineFeedbackControlProps {
|
|
553
|
+
/** Unique key identifying the feedback target (e.g. factor key). */
|
|
554
|
+
feedbackKey: string
|
|
555
|
+
/** Persisted/initial feedback to hydrate from. */
|
|
556
|
+
initialFeedback?: { type: "up" | "down"; detail: string; ownershipLabel?: string }
|
|
557
|
+
/** Called when user submits or clears feedback. */
|
|
558
|
+
onFeedback?: (key: string, type: "up" | "down" | null, detail?: string) => void
|
|
559
|
+
/** Test ID prefix for all sub-elements. */
|
|
560
|
+
testIdPrefix?: string
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Compact inline thumb-up/thumb-down feedback with optional detail text.
|
|
565
|
+
* Used by PriorityFactorRow and any other component that needs
|
|
566
|
+
* a lightweight feedback control.
|
|
567
|
+
*/
|
|
568
|
+
export function InlineFeedbackControl({
|
|
569
|
+
feedbackKey,
|
|
570
|
+
initialFeedback,
|
|
571
|
+
onFeedback,
|
|
572
|
+
testIdPrefix = "inline-feedback",
|
|
573
|
+
}: InlineFeedbackControlProps) {
|
|
574
|
+
const [thumbState, setThumbState] = React.useState<"up" | "down" | null>(
|
|
575
|
+
initialFeedback?.type ?? null,
|
|
576
|
+
)
|
|
577
|
+
const [showInput, setShowInput] = React.useState(false)
|
|
578
|
+
const [detailText, setDetailText] = React.useState(initialFeedback?.detail ?? "")
|
|
579
|
+
const [saved, setSaved] = React.useState(!!initialFeedback)
|
|
580
|
+
const [savedDetail, setSavedDetail] = React.useState(initialFeedback?.detail ?? "")
|
|
581
|
+
const ownershipLabel = initialFeedback?.ownershipLabel ?? "Your feedback"
|
|
582
|
+
|
|
583
|
+
// Sync with initialFeedback prop changes
|
|
584
|
+
React.useEffect(() => {
|
|
585
|
+
if (initialFeedback) {
|
|
586
|
+
setThumbState(initialFeedback.type)
|
|
587
|
+
setSaved(true)
|
|
588
|
+
setSavedDetail(initialFeedback.detail)
|
|
589
|
+
}
|
|
590
|
+
}, [initialFeedback])
|
|
591
|
+
|
|
592
|
+
const handleThumbClick = React.useCallback(
|
|
593
|
+
(type: "up" | "down") => {
|
|
594
|
+
if (thumbState === type) {
|
|
595
|
+
// Toggle off
|
|
596
|
+
setThumbState(null)
|
|
597
|
+
setShowInput(false)
|
|
598
|
+
setSaved(false)
|
|
599
|
+
onFeedback?.(feedbackKey, null)
|
|
600
|
+
} else {
|
|
601
|
+
setThumbState(type)
|
|
602
|
+
setShowInput(true)
|
|
603
|
+
setSaved(false)
|
|
604
|
+
}
|
|
605
|
+
},
|
|
606
|
+
[thumbState, feedbackKey, onFeedback],
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
const handleSubmitDetail = React.useCallback(() => {
|
|
610
|
+
if (!thumbState) return
|
|
611
|
+
const text = detailText.trim()
|
|
612
|
+
onFeedback?.(feedbackKey, thumbState, text)
|
|
613
|
+
setSaved(true)
|
|
614
|
+
setSavedDetail(text)
|
|
615
|
+
setShowInput(false)
|
|
616
|
+
}, [thumbState, detailText, feedbackKey, onFeedback])
|
|
617
|
+
|
|
618
|
+
return (
|
|
619
|
+
<div>
|
|
620
|
+
{saved && !showInput ? (
|
|
621
|
+
/* Persisted / saved indicator */
|
|
622
|
+
<button
|
|
623
|
+
type="button"
|
|
624
|
+
onClick={() => {
|
|
625
|
+
setDetailText(savedDetail)
|
|
626
|
+
setShowInput(true)
|
|
627
|
+
setSaved(false)
|
|
628
|
+
}}
|
|
629
|
+
className="group flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
|
630
|
+
data-testid={`${testIdPrefix}-feedback-persisted-${feedbackKey}`}
|
|
631
|
+
>
|
|
632
|
+
<span className="font-medium">{ownershipLabel}:</span>
|
|
633
|
+
{thumbState === "up" ? (
|
|
634
|
+
<ThumbsUp className="h-[10px] w-[10px]" />
|
|
635
|
+
) : (
|
|
636
|
+
<ThumbsDown className="h-[10px] w-[10px]" />
|
|
637
|
+
)}
|
|
638
|
+
{savedDetail && (
|
|
639
|
+
<span className="max-w-[180px] truncate text-muted-foreground/70">
|
|
640
|
+
{savedDetail}
|
|
641
|
+
</span>
|
|
642
|
+
)}
|
|
643
|
+
<Pencil className="h-[9px] w-[9px] opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
644
|
+
</button>
|
|
645
|
+
) : (
|
|
646
|
+
<div className="flex items-center gap-1.5">
|
|
647
|
+
{/* Inline thumb buttons */}
|
|
648
|
+
<button
|
|
649
|
+
type="button"
|
|
650
|
+
onClick={() => handleThumbClick("up")}
|
|
651
|
+
className={cn(
|
|
652
|
+
"p-1 rounded transition-colors",
|
|
653
|
+
thumbState === "up"
|
|
654
|
+
? "text-foreground bg-muted"
|
|
655
|
+
: "text-muted-foreground/40 hover:text-foreground hover:bg-muted/50",
|
|
656
|
+
)}
|
|
657
|
+
title="This is accurate"
|
|
658
|
+
data-testid={`${testIdPrefix}-thumb-up-${feedbackKey}`}
|
|
659
|
+
>
|
|
660
|
+
<ThumbsUp className="h-[10px] w-[10px]" />
|
|
661
|
+
</button>
|
|
662
|
+
<button
|
|
663
|
+
type="button"
|
|
664
|
+
onClick={() => handleThumbClick("down")}
|
|
665
|
+
className={cn(
|
|
666
|
+
"p-1 rounded transition-colors",
|
|
667
|
+
thumbState === "down"
|
|
668
|
+
? "text-red-600 bg-red-50"
|
|
669
|
+
: "text-muted-foreground/40 hover:text-red-600 hover:bg-red-50/50",
|
|
670
|
+
)}
|
|
671
|
+
title="Report issue"
|
|
672
|
+
data-testid={`${testIdPrefix}-thumb-down-${feedbackKey}`}
|
|
673
|
+
>
|
|
674
|
+
<ThumbsDown className="h-[10px] w-[10px]" />
|
|
675
|
+
</button>
|
|
676
|
+
|
|
677
|
+
{/* Transient "Saved" pill */}
|
|
678
|
+
{saved && (
|
|
679
|
+
<span
|
|
680
|
+
className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-600"
|
|
681
|
+
role="status"
|
|
682
|
+
data-testid={`${testIdPrefix}-saved-${feedbackKey}`}
|
|
683
|
+
>
|
|
684
|
+
<Check className="h-[10px] w-[10px]" />
|
|
685
|
+
Saved
|
|
686
|
+
</span>
|
|
687
|
+
)}
|
|
688
|
+
</div>
|
|
689
|
+
)}
|
|
690
|
+
|
|
691
|
+
{/* Inline detail input */}
|
|
692
|
+
{showInput && thumbState && (
|
|
693
|
+
<div className="mt-1.5">
|
|
694
|
+
<input
|
|
695
|
+
type="text"
|
|
696
|
+
value={detailText}
|
|
697
|
+
onChange={(e) => setDetailText(e.target.value)}
|
|
698
|
+
onKeyDown={(e) => {
|
|
699
|
+
if (e.key === "Enter") handleSubmitDetail()
|
|
700
|
+
if (e.key === "Escape") setShowInput(false)
|
|
701
|
+
}}
|
|
702
|
+
placeholder={
|
|
703
|
+
thumbState === "up"
|
|
704
|
+
? "What\u2019s accurate? (optional)"
|
|
705
|
+
: "What\u2019s wrong? (optional)"
|
|
706
|
+
}
|
|
707
|
+
className="w-full h-6 rounded border border-border bg-background px-2 text-[11px] text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-ring"
|
|
708
|
+
data-testid={`${testIdPrefix}-detail-input-${feedbackKey}`}
|
|
709
|
+
/>
|
|
710
|
+
<div className="mt-1 flex items-center gap-1.5">
|
|
711
|
+
<button
|
|
712
|
+
type="button"
|
|
713
|
+
onClick={handleSubmitDetail}
|
|
714
|
+
className="bg-foreground text-background rounded px-2 py-0.5 text-[10px] font-semibold"
|
|
715
|
+
data-testid={`${testIdPrefix}-submit-${feedbackKey}`}
|
|
716
|
+
>
|
|
717
|
+
Submit
|
|
718
|
+
</button>
|
|
719
|
+
<button
|
|
720
|
+
type="button"
|
|
721
|
+
onClick={() => setShowInput(false)}
|
|
722
|
+
className="border border-border rounded px-2 py-0.5 text-[10px] font-medium"
|
|
723
|
+
>
|
|
724
|
+
Cancel
|
|
725
|
+
</button>
|
|
726
|
+
</div>
|
|
727
|
+
</div>
|
|
728
|
+
)}
|
|
729
|
+
</div>
|
|
730
|
+
)
|
|
731
|
+
}
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
} from "lucide-react"
|
|
16
16
|
import type { LucideIcon } from "lucide-react"
|
|
17
17
|
import { FeedbackFooter } from "./feedback-primitives"
|
|
18
|
-
import type { FeedbackChipTree, FeedbackSubmitData } from "./feedback-primitives"
|
|
18
|
+
import type { FeedbackChipTree, FeedbackSubmitData, PersistedFeedbackData } from "./feedback-primitives"
|
|
19
19
|
import { cn } from "../lib/utils"
|
|
20
20
|
import type {
|
|
21
21
|
QueueItem,
|
|
@@ -266,6 +266,7 @@ function StructuredSignalRow({ item, bucketKey, signal, tone, onOpenSignalBucket
|
|
|
266
266
|
const IconComponent = resolveIcon(signal.signalTypeName)
|
|
267
267
|
const toneClass = tone ? (SIGNAL_TONE_CLASSES[tone] ?? DEFAULT_TONE_CLASS) : DEFAULT_TONE_CLASS
|
|
268
268
|
const isCombined = signal.signalTypeName === "combined_signal" && signal.components && signal.components.length > 0
|
|
269
|
+
const hasBalance = Boolean(signal.currentBalance || signal.balanceContext)
|
|
269
270
|
|
|
270
271
|
const rowContent = (
|
|
271
272
|
<>
|
|
@@ -304,6 +305,26 @@ function StructuredSignalRow({ item, bucketKey, signal, tone, onOpenSignalBucket
|
|
|
304
305
|
|
|
305
306
|
{/* Slot 5: Chevron */}
|
|
306
307
|
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/60 transition-transform group-hover:translate-x-0.5 group-hover:text-foreground/50" />
|
|
308
|
+
|
|
309
|
+
{/* Balance context strip — spans full row below grid columns */}
|
|
310
|
+
{hasBalance && (
|
|
311
|
+
<div
|
|
312
|
+
className="col-span-full mt-0.5 text-[10px] text-muted-foreground/70"
|
|
313
|
+
data-testid="balance-context-strip"
|
|
314
|
+
>
|
|
315
|
+
{signal.currentBalance && (
|
|
316
|
+
<span>
|
|
317
|
+
Current balance <span className="font-medium text-muted-foreground">{signal.currentBalance}</span>
|
|
318
|
+
</span>
|
|
319
|
+
)}
|
|
320
|
+
{signal.balanceContext && (
|
|
321
|
+
<span>
|
|
322
|
+
{signal.currentBalance ? " · " : ""}
|
|
323
|
+
{signal.balanceContext}
|
|
324
|
+
</span>
|
|
325
|
+
)}
|
|
326
|
+
</div>
|
|
327
|
+
)}
|
|
307
328
|
</>
|
|
308
329
|
)
|
|
309
330
|
|
|
@@ -405,9 +426,11 @@ interface WhyCardProps {
|
|
|
405
426
|
panelId: string
|
|
406
427
|
onOpenSignalBucket?: ScoreWhyChipsProps["onOpenSignalBucket"]
|
|
407
428
|
onBucketFeedback?: (bucketKey: string, data: FeedbackSubmitData) => void
|
|
429
|
+
/** Persisted bucket-level feedback to hydrate from. */
|
|
430
|
+
initialBucketFeedback?: PersistedFeedbackData | null
|
|
408
431
|
}
|
|
409
432
|
|
|
410
|
-
function WhyCard({ bucket, signals, item, panelId, onOpenSignalBucket, onBucketFeedback }: WhyCardProps) {
|
|
433
|
+
function WhyCard({ bucket, signals, item, panelId, onOpenSignalBucket, onBucketFeedback, initialBucketFeedback }: WhyCardProps) {
|
|
411
434
|
const [showAll, setShowAll] = React.useState(false)
|
|
412
435
|
const [bucketFeedback, setBucketFeedback] = React.useState<"positive" | "negative" | null>(null)
|
|
413
436
|
const totalCount = bucket.signalCount ?? signals.length
|
|
@@ -488,6 +511,8 @@ function WhyCard({ bucket, signals, item, panelId, onOpenSignalBucket, onBucketF
|
|
|
488
511
|
negativeChips={BUCKET_NEGATIVE_CHIPS}
|
|
489
512
|
negativePrompt="Was this bucket useful?"
|
|
490
513
|
positivePrompt="Thanks! What was useful about this bucket?"
|
|
514
|
+
initialFeedback={initialBucketFeedback}
|
|
515
|
+
feedbackKey={bucket.key}
|
|
491
516
|
/>
|
|
492
517
|
</div>
|
|
493
518
|
)}
|
|
@@ -561,6 +586,7 @@ export function ScoreWhyChips({
|
|
|
561
586
|
panelId={selectedPanelId}
|
|
562
587
|
onOpenSignalBucket={onOpenSignalBucket}
|
|
563
588
|
onBucketFeedback={signalData.onBucketFeedback}
|
|
589
|
+
initialBucketFeedback={signalData.initialBucketFeedback?.[selectedBucket.key]}
|
|
564
590
|
/>
|
|
565
591
|
)}
|
|
566
592
|
</div>
|
|
@@ -19,8 +19,8 @@ import {
|
|
|
19
19
|
Info,
|
|
20
20
|
} from "lucide-react"
|
|
21
21
|
import { cn } from "../lib/utils"
|
|
22
|
-
import { FeedbackFooter } from "./feedback-primitives"
|
|
23
|
-
import type { FeedbackChipTree, FeedbackSubmitData } from "./feedback-primitives"
|
|
22
|
+
import { FeedbackFooter, InlineFeedbackControl } from "./feedback-primitives"
|
|
23
|
+
import type { FeedbackChipTree, FeedbackSubmitData, PersistedFeedbackData } from "./feedback-primitives"
|
|
24
24
|
import type { SignalScoreUrgencyLabel } from "../prototype/prototype-config"
|
|
25
25
|
import { getSignalScoreUrgencyLabel, scoreRangeForUrgency, SIGNAL_TONE_CLASSES } from "./score-why-chips"
|
|
26
26
|
|
|
@@ -58,6 +58,12 @@ export interface SignalPriorityPopoverProps {
|
|
|
58
58
|
feedbackChips?: FeedbackChipTree[]
|
|
59
59
|
onFeedbackSubmit?: (data: FeedbackSubmitData) => void
|
|
60
60
|
className?: string
|
|
61
|
+
/** Persisted factor-level feedback (keyed by factor key). */
|
|
62
|
+
initialFactorFeedback?: Record<string, { type: "up" | "down"; detail: string; ownershipLabel?: string }>
|
|
63
|
+
/** Callback when user submits factor-level feedback. */
|
|
64
|
+
onFactorFeedback?: (factorKey: string, type: "up" | "down" | null, detail?: string) => void
|
|
65
|
+
/** Persisted priority-level feedback for the footer. */
|
|
66
|
+
initialPriorityFeedback?: PersistedFeedbackData | null
|
|
61
67
|
}
|
|
62
68
|
|
|
63
69
|
// ---------------------------------------------------------------------------
|
|
@@ -152,7 +158,13 @@ function DirectionIcon({ direction }: { direction: PriorityFactor["direction"] }
|
|
|
152
158
|
// PriorityFactorRow
|
|
153
159
|
// ---------------------------------------------------------------------------
|
|
154
160
|
|
|
155
|
-
|
|
161
|
+
interface PriorityFactorRowProps {
|
|
162
|
+
factor: PriorityFactor
|
|
163
|
+
initialFeedback?: { type: "up" | "down"; detail: string; ownershipLabel?: string }
|
|
164
|
+
onFactorFeedback?: (factorKey: string, type: "up" | "down" | null, detail?: string) => void
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function PriorityFactorRow({ factor, initialFeedback, onFactorFeedback }: PriorityFactorRowProps) {
|
|
156
168
|
const IconComponent = FACTOR_ICONS[factor.icon] ?? Activity
|
|
157
169
|
const toneClasses = TONE_ICON_CLASSES[factor.tone]
|
|
158
170
|
const directionClasses = DIRECTION_CLASSES[factor.direction]
|
|
@@ -224,6 +236,21 @@ function PriorityFactorRow({ factor }: { factor: PriorityFactor }) {
|
|
|
224
236
|
|
|
225
237
|
{/* empty grid cell under score column */}
|
|
226
238
|
<div />
|
|
239
|
+
|
|
240
|
+
{/* Factor-level feedback row (spans icon + content columns) */}
|
|
241
|
+
{onFactorFeedback && (
|
|
242
|
+
<>
|
|
243
|
+
<div />
|
|
244
|
+
<div className="col-span-2 mt-1">
|
|
245
|
+
<InlineFeedbackControl
|
|
246
|
+
feedbackKey={factor.key}
|
|
247
|
+
initialFeedback={initialFeedback}
|
|
248
|
+
onFeedback={onFactorFeedback}
|
|
249
|
+
testIdPrefix="factor"
|
|
250
|
+
/>
|
|
251
|
+
</div>
|
|
252
|
+
</>
|
|
253
|
+
)}
|
|
227
254
|
</div>
|
|
228
255
|
)
|
|
229
256
|
}
|
|
@@ -241,6 +268,9 @@ export function SignalPriorityPopover({
|
|
|
241
268
|
feedbackChips,
|
|
242
269
|
onFeedbackSubmit,
|
|
243
270
|
className,
|
|
271
|
+
initialFactorFeedback,
|
|
272
|
+
onFactorFeedback,
|
|
273
|
+
initialPriorityFeedback,
|
|
244
274
|
}: SignalPriorityPopoverProps) {
|
|
245
275
|
const urgencyLabel = getSignalScoreUrgencyLabel(score, providedLabel)
|
|
246
276
|
const scoreRange = scoreRangeForUrgency(urgencyLabel)
|
|
@@ -252,6 +282,9 @@ export function SignalPriorityPopover({
|
|
|
252
282
|
const triggerHover = URGENCY_TRIGGER_HOVER[urgencyLabel]
|
|
253
283
|
const triggerOpen = URGENCY_TRIGGER_OPEN[urgencyLabel]
|
|
254
284
|
|
|
285
|
+
// Derive a stable feedbackKey for the footer from score + urgencyLabel
|
|
286
|
+
const footerFeedbackKey = `priority-${score}-${urgencyLabel}`
|
|
287
|
+
|
|
255
288
|
return (
|
|
256
289
|
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
|
|
257
290
|
<PopoverPrimitive.Trigger asChild>
|
|
@@ -332,7 +365,12 @@ export function SignalPriorityPopover({
|
|
|
332
365
|
{/* Factor rows */}
|
|
333
366
|
<div className="divide-y divide-border/40">
|
|
334
367
|
{factors.map((factor) => (
|
|
335
|
-
<PriorityFactorRow
|
|
368
|
+
<PriorityFactorRow
|
|
369
|
+
key={factor.key}
|
|
370
|
+
factor={factor}
|
|
371
|
+
initialFeedback={initialFactorFeedback?.[factor.key]}
|
|
372
|
+
onFactorFeedback={onFactorFeedback}
|
|
373
|
+
/>
|
|
336
374
|
))}
|
|
337
375
|
</div>
|
|
338
376
|
</>
|
|
@@ -349,6 +387,8 @@ export function SignalPriorityPopover({
|
|
|
349
387
|
negativeChips={feedbackChips ?? DEFAULT_PRIORITY_FEEDBACK_CHIPS}
|
|
350
388
|
positivePrompt="Thanks. Anything to keep about this score?"
|
|
351
389
|
className="px-4 py-3"
|
|
390
|
+
initialFeedback={initialPriorityFeedback}
|
|
391
|
+
feedbackKey={footerFeedbackKey}
|
|
352
392
|
/>
|
|
353
393
|
</div>
|
|
354
394
|
)}
|
package/src/index.ts
CHANGED
|
@@ -38,8 +38,8 @@ export * from "./components/dialog"
|
|
|
38
38
|
export * from "./components/dropdown-menu"
|
|
39
39
|
export * from "./components/empty-state"
|
|
40
40
|
export * from "./components/entity-panel"
|
|
41
|
-
export { FeedbackFooter, FeedbackChipGroup, FeedbackInput, FeedbackActions } from "./components/feedback-primitives"
|
|
42
|
-
export type { FeedbackFooterProps, FeedbackChipTree, FeedbackChipGroupProps, FeedbackInputProps, FeedbackActionsProps, FeedbackSubmitData } from "./components/feedback-primitives"
|
|
41
|
+
export { FeedbackFooter, FeedbackChipGroup, FeedbackInput, FeedbackActions, InlineFeedbackControl } from "./components/feedback-primitives"
|
|
42
|
+
export type { FeedbackFooterProps, FeedbackChipTree, FeedbackChipGroupProps, FeedbackInputProps, FeedbackActionsProps, FeedbackSubmitData, PersistedFeedbackData, InlineFeedbackControlProps } from "./components/feedback-primitives"
|
|
43
43
|
export { SignalPriorityPopover } from "./components/signal-priority-popover"
|
|
44
44
|
export type { SignalPriorityPopoverProps, PriorityFactor } from "./components/signal-priority-popover"
|
|
45
45
|
export * from "./components/filter-chip"
|
|
@@ -16,7 +16,7 @@ import type { TimelineEvent } from "../components/timeline-activity"
|
|
|
16
16
|
import type { ApprovalState } from "../components/signal-feedback-inline"
|
|
17
17
|
import type { LucideIcon } from "lucide-react"
|
|
18
18
|
import type { PriorityFactor } from "../components/signal-priority-popover"
|
|
19
|
-
import type { FeedbackChipTree, FeedbackSubmitData } from "../components/feedback-primitives"
|
|
19
|
+
import type { FeedbackChipTree, FeedbackSubmitData, PersistedFeedbackData } from "../components/feedback-primitives"
|
|
20
20
|
|
|
21
21
|
// ---------------------------------------------------------------------------
|
|
22
22
|
// Shared
|
|
@@ -57,6 +57,10 @@ export interface SignalScoreExplanationSignal {
|
|
|
57
57
|
counterparty?: string
|
|
58
58
|
/** Component breakdown for combined signals. */
|
|
59
59
|
components?: Array<{ type: string; count: number }>
|
|
60
|
+
/** Current balance value (e.g., "$3.0M") for balance context strip. */
|
|
61
|
+
currentBalance?: string
|
|
62
|
+
/** Additional balance context text (e.g., "down from $23M"). */
|
|
63
|
+
balanceContext?: string
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
export interface SignalScoreExplanationBucket {
|
|
@@ -97,6 +101,12 @@ export interface SignalScoreData {
|
|
|
97
101
|
/** @deprecated The compact score UX no longer renders score-level thumbs by default. */
|
|
98
102
|
initialScoreFeedback?: { type: "up" | "down"; pills: string[]; detail: string } | null
|
|
99
103
|
initialFactorFeedback?: Record<string, { type: "up" | "down"; detail: string }>
|
|
104
|
+
/** Factor-level feedback for the priority popover rows (keyed by factor key). */
|
|
105
|
+
initialFactorPopoverFeedback?: Record<string, { type: "up" | "down"; detail: string; ownershipLabel?: string }>
|
|
106
|
+
/** Persisted bucket-level feedback, keyed by bucket key. */
|
|
107
|
+
initialBucketFeedback?: Record<string, PersistedFeedbackData>
|
|
108
|
+
/** Persisted priority-level feedback for the popover footer. */
|
|
109
|
+
initialPriorityFeedback?: PersistedFeedbackData | null
|
|
100
110
|
/** Priority factors for the popover breakdown. */
|
|
101
111
|
priorityFactors?: PriorityFactor[]
|
|
102
112
|
/** Negative feedback chip tree for the priority popover. */
|
|
@@ -272,6 +272,9 @@ export function DetailView({
|
|
|
272
272
|
metaText={undefined}
|
|
273
273
|
feedbackChips={signalData.priorityFeedbackChips}
|
|
274
274
|
onFeedbackSubmit={signalData.onPriorityFeedback}
|
|
275
|
+
initialFactorFeedback={signalData.initialFactorPopoverFeedback}
|
|
276
|
+
onFactorFeedback={signalData.onFactorFeedback}
|
|
277
|
+
initialPriorityFeedback={signalData.initialPriorityFeedback}
|
|
275
278
|
/>
|
|
276
279
|
{signalData.timeChipLabel && (
|
|
277
280
|
<Badge variant="outline" title={signalData.timeChipDetail ?? undefined}>
|