@handled-ai/design-system 0.17.2 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/charts/chart.d.ts +1 -1
  2. package/dist/components/actor-byline.d.ts +3 -0
  3. package/dist/components/actor-byline.js +5 -0
  4. package/dist/components/actor-byline.js.map +1 -0
  5. package/dist/components/feedback-primitives.d.ts +21 -2
  6. package/dist/components/feedback-primitives.js +90 -6
  7. package/dist/components/feedback-primitives.js.map +1 -1
  8. package/dist/components/performance-metrics-table.d.ts +2 -1
  9. package/dist/components/performance-metrics-table.js +78 -46
  10. package/dist/components/performance-metrics-table.js.map +1 -1
  11. package/dist/components/score-why-chips.d.ts +1 -1
  12. package/dist/components/score-why-chips.js +26 -5
  13. package/dist/components/score-why-chips.js.map +1 -1
  14. package/dist/components/signal-priority-popover.d.ts +1 -1
  15. package/dist/components/signal-priority-popover.js +172 -7
  16. package/dist/components/signal-priority-popover.js.map +1 -1
  17. package/dist/index.d.ts +2 -2
  18. package/dist/index.js.map +1 -1
  19. package/dist/prototype/index.d.ts +1 -1
  20. package/dist/prototype/prototype-accounts-view.d.ts +1 -1
  21. package/dist/prototype/prototype-admin-view.d.ts +1 -1
  22. package/dist/prototype/prototype-config.d.ts +1 -1
  23. package/dist/prototype/prototype-inbox-view.d.ts +1 -1
  24. package/dist/prototype/prototype-inbox-view.js +4 -1
  25. package/dist/prototype/prototype-inbox-view.js.map +1 -1
  26. package/dist/prototype/prototype-insights-view.d.ts +1 -1
  27. package/dist/prototype/prototype-shell.d.ts +1 -1
  28. package/dist/{signal-priority-popover-DQ_VuHac.d.ts → signal-priority-popover-DWaAMhPI.d.ts} +26 -2
  29. package/package.json +3 -1
  30. package/src/components/__tests__/performance-metrics-table.test.tsx +54 -0
  31. package/src/components/__tests__/user-display.test.tsx +75 -0
  32. package/src/components/__tests__/wit-636-feedback-states.test.tsx +546 -0
  33. package/src/components/actor-byline.tsx +1 -0
  34. package/src/components/feedback-primitives.tsx +148 -26
  35. package/src/components/performance-metrics-table.tsx +99 -63
  36. package/src/components/score-why-chips.tsx +28 -2
  37. package/src/components/signal-priority-popover.tsx +194 -3
  38. package/src/index.ts +1 -1
  39. package/src/lib/__tests__/user-display.test.ts +53 -11
  40. package/src/prototype/prototype-config.ts +11 -1
  41. 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,43 @@ 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 (to guard against prop overwrites). */
247
+ const [isEditing, setIsEditing] = React.useState(false)
248
+ /** Track the last synced feedbackKey to detect key changes. */
249
+ const lastKeyRef = React.useRef<string | undefined>(feedbackKey)
250
+
251
+ // Sync initialFeedback into local state via useEffect keyed on feedbackKey.
252
+ // When feedbackKey changes, reset to new target. Preserve active edits
253
+ // when feedbackKey stays the same.
254
+ React.useEffect(() => {
255
+ const keyChanged = feedbackKey !== lastKeyRef.current
256
+ lastKeyRef.current = feedbackKey
257
+
258
+ if (keyChanged) {
259
+ // Key changed — full reset to new target
260
+ setPersisted(initialFeedback ?? null)
261
+ setSubmitted(false)
262
+ setExpanded(false)
263
+ setIsEditing(false)
264
+ if (initialFeedback) {
265
+ onFeedbackChange(initialFeedback.sentiment)
266
+ } else {
267
+ onFeedbackChange(null)
268
+ }
269
+ } else if (!isEditing) {
270
+ // Same key, not actively editing — safe to sync
271
+ setPersisted(initialFeedback ?? null)
272
+ if (initialFeedback) {
273
+ onFeedbackChange(initialFeedback.sentiment)
274
+ }
275
+ }
276
+ }, [initialFeedback, feedbackKey]) // eslint-disable-line react-hooks/exhaustive-deps -- reads isEditing as guard, not trigger
217
277
 
218
278
  // Reset state when feedback collapses
219
279
  const resetState = React.useCallback(() => {
@@ -223,6 +283,7 @@ export function FeedbackFooter({
223
283
  setAdditionalPills([])
224
284
  setDetailText("")
225
285
  setActiveTreeIndex(null)
286
+ setIsEditing(false)
226
287
  }, [])
227
288
 
228
289
  const handleSentimentClick = React.useCallback(
@@ -231,10 +292,26 @@ export function FeedbackFooter({
231
292
  // Reset chip state when switching sentiment, then expand
232
293
  resetState()
233
294
  setExpanded(true)
295
+ setSubmitted(false)
296
+ setPersisted(null)
297
+ setIsEditing(true)
234
298
  },
235
299
  [onFeedbackChange, resetState],
236
300
  )
237
301
 
302
+ /** Open the persisted indicator for editing. */
303
+ const handlePersistedClick = React.useCallback(() => {
304
+ if (!persisted) return
305
+ onFeedbackChange(persisted.sentiment)
306
+ setSelectedTier1(persisted.reasonTop ?? null)
307
+ setSelectedTier2(persisted.reasonSub ?? null)
308
+ setAdditionalPills(persisted.pills ?? [])
309
+ setDetailText(persisted.detail ?? "")
310
+ setExpanded(true)
311
+ setSubmitted(false)
312
+ setIsEditing(true)
313
+ }, [persisted, onFeedbackChange])
314
+
238
315
  const handleTier1Toggle = React.useCallback(
239
316
  (chipLabel: string) => {
240
317
  if (selectedTier1 === chipLabel) {
@@ -295,7 +372,16 @@ export function FeedbackFooter({
295
372
  pills: additionalPills,
296
373
  detail: detailText,
297
374
  })
298
- resetState()
375
+ // Show transient "Saved" confirmation
376
+ setSubmitted(true)
377
+ // Collapse expansion but keep sentiment visible
378
+ setExpanded(false)
379
+ setSelectedTier1(null)
380
+ setSelectedTier2(null)
381
+ setAdditionalPills([])
382
+ setDetailText("")
383
+ setActiveTreeIndex(null)
384
+ setIsEditing(false)
299
385
  }, [
300
386
  feedback,
301
387
  selectedTier1,
@@ -303,7 +389,6 @@ export function FeedbackFooter({
303
389
  additionalPills,
304
390
  detailText,
305
391
  onSubmit,
306
- resetState,
307
392
  ])
308
393
 
309
394
  const handleCancel = React.useCallback(() => {
@@ -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
- <div className="flex items-center gap-3">
418
+ {showPersistedIndicator ? (
419
+ /* Persisted feedback indicator — clickable to reopen editor */
331
420
  <button
332
421
  type="button"
333
- onClick={() => handleSentimentClick("positive")}
334
- className={cn(
335
- "flex gap-1 items-center text-[11px] font-medium rounded-md px-2 py-1 transition-colors",
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
- <ThumbsUp className="h-[11px] w-[11px]" />
342
- Helpful
343
- </button>
344
- <button
345
- type="button"
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
- <ThumbsDown className="h-[11px] w-[11px]" />
355
- Not helpful
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
- </div>
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
  )}
@@ -52,6 +52,7 @@ interface PerformanceMetricsTableProps {
52
52
  title?: string
53
53
  entityColumnLabel?: string
54
54
  primaryMetricColumnLabel?: string
55
+ primaryMetricDisplayMode?: "progress" | "value"
55
56
  rateColumnLabel?: string
56
57
  metricOneColumnLabel?: string
57
58
  metricTwoColumnLabel?: string
@@ -179,9 +180,48 @@ function getProgressStatus(value: number, target: number) {
179
180
  }
180
181
  }
181
182
 
183
+ function PrimaryMetricCell({
184
+ row,
185
+ displayMode,
186
+ }: {
187
+ row: PerformanceMetricsTableRow
188
+ displayMode: "progress" | "value"
189
+ }) {
190
+ if (displayMode === "value") {
191
+ return (
192
+ <div className="text-sm font-bold text-foreground">
193
+ {row.primaryValue}
194
+ </div>
195
+ )
196
+ }
197
+
198
+ const percentage = (row.primaryValue / row.primaryTarget) * 100
199
+ const progress = getProgressStatus(row.primaryValue, row.primaryTarget)
200
+
201
+ return (
202
+ <div className="flex items-center gap-2">
203
+ <span className="shrink-0">{progress.icon}</span>
204
+ <div className="w-full max-w-[180px]">
205
+ <div className="mb-1 text-sm font-bold text-foreground">
206
+ {row.primaryValue}/{row.primaryTarget}
207
+ </div>
208
+ <div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
209
+ <div
210
+ className={cn("h-full rounded-full", progress.color)}
211
+ style={{ width: `${Math.min(100, percentage)}%` }}
212
+ />
213
+ </div>
214
+ <div className={cn("mt-1 text-xs font-medium", progress.textColor)}>
215
+ {Math.round(percentage)}%
216
+ </div>
217
+ </div>
218
+ </div>
219
+ )
220
+ }
221
+
182
222
  function sortRows(
183
223
  rows: PerformanceMetricsTableRow[],
184
- sortId: PerformanceMetricsTableSortOption["id"]
224
+ sortId: PerformanceMetricsTableSortOption["id"],
185
225
  ) {
186
226
  const copy = [...rows]
187
227
  switch (sortId) {
@@ -201,23 +241,28 @@ export function PerformanceMetricsTable({
201
241
  title = "Performance Table",
202
242
  entityColumnLabel = "Entity",
203
243
  primaryMetricColumnLabel = "Primary Goal",
244
+ primaryMetricDisplayMode = "progress",
204
245
  rateColumnLabel = "Rate",
205
246
  metricOneColumnLabel = "Metric One",
206
247
  metricTwoColumnLabel = "Metric Two",
207
248
  metricThreeColumnLabel = "Metric Three",
208
249
  metricFourColumnLabel = "Metric Four",
209
250
  viewOptions = ["By Entity"],
210
- roleOptions = ["All", "Senior Coordinator", "Coordinator", "Junior Coordinator"],
251
+ roleOptions = [
252
+ "All",
253
+ "Senior Coordinator",
254
+ "Coordinator",
255
+ "Junior Coordinator",
256
+ ],
211
257
  sortOptions = DEFAULT_SORT_OPTIONS,
212
258
  rows = DEFAULT_ROWS,
213
259
  pageSize = 6,
214
260
  searchPlaceholder = "Search rows...",
215
261
  }: PerformanceMetricsTableProps) {
216
262
  const [view, setView] = React.useState(viewOptions[0] ?? "By Entity")
217
- const [sortId, setSortId] =
218
- React.useState<PerformanceMetricsTableSortOption["id"]>(
219
- sortOptions[0]?.id ?? "primary-desc"
220
- )
263
+ const [sortId, setSortId] = React.useState<
264
+ PerformanceMetricsTableSortOption["id"]
265
+ >(sortOptions[0]?.id ?? "primary-desc")
221
266
  const [roleFilter, setRoleFilter] = React.useState(roleOptions[0] ?? "All")
222
267
  const [search, setSearch] = React.useState("")
223
268
  const [page, setPage] = React.useState(1)
@@ -237,7 +282,7 @@ export function PerformanceMetricsTable({
237
282
 
238
283
  const sortedRows = React.useMemo(
239
284
  () => sortRows(filteredRows, sortId),
240
- [filteredRows, sortId]
285
+ [filteredRows, sortId],
241
286
  )
242
287
 
243
288
  const pageCount = Math.max(1, Math.ceil(sortedRows.length / pageSize))
@@ -285,7 +330,10 @@ export function PerformanceMetricsTable({
285
330
  </DropdownMenuTrigger>
286
331
  <DropdownMenuContent align="end" className="w-56">
287
332
  {sortOptions.map((option) => (
288
- <DropdownMenuItem key={option.id} onClick={() => setSortId(option.id)}>
333
+ <DropdownMenuItem
334
+ key={option.id}
335
+ onClick={() => setSortId(option.id)}
336
+ >
289
337
  {option.label}
290
338
  </DropdownMenuItem>
291
339
  ))}
@@ -301,7 +349,10 @@ export function PerformanceMetricsTable({
301
349
  </DropdownMenuTrigger>
302
350
  <DropdownMenuContent align="end">
303
351
  {roleOptions.map((option) => (
304
- <DropdownMenuItem key={option} onClick={() => setRoleFilter(option)}>
352
+ <DropdownMenuItem
353
+ key={option}
354
+ onClick={() => setRoleFilter(option)}
355
+ >
305
356
  {option}
306
357
  </DropdownMenuItem>
307
358
  ))}
@@ -346,61 +397,45 @@ export function PerformanceMetricsTable({
346
397
  </TableRow>
347
398
  </TableHeader>
348
399
  <TableBody>
349
- {paginatedRows.map((row) => {
350
- const percentage = (row.primaryValue / row.primaryTarget) * 100
351
- const progress = getProgressStatus(row.primaryValue, row.primaryTarget)
400
+ {paginatedRows.map((row) => (
401
+ <TableRow key={row.id} className="hover:bg-muted/30">
402
+ <TableCell className="px-4 py-3">
403
+ <div className="flex items-center gap-3">
404
+ <Avatar className="h-8 w-8 border border-border">
405
+ <AvatarFallback className="bg-emerald-100 text-[11px] font-medium text-emerald-700">
406
+ {row.avatarFallback}
407
+ </AvatarFallback>
408
+ </Avatar>
409
+ <span className="text-sm font-medium text-foreground">
410
+ {row.label}
411
+ </span>
412
+ </div>
413
+ </TableCell>
352
414
 
353
- return (
354
- <TableRow key={row.id} className="hover:bg-muted/30">
355
- <TableCell className="px-4 py-3">
356
- <div className="flex items-center gap-3">
357
- <Avatar className="h-8 w-8 border border-border">
358
- <AvatarFallback className="bg-emerald-100 text-[11px] font-medium text-emerald-700">
359
- {row.avatarFallback}
360
- </AvatarFallback>
361
- </Avatar>
362
- <span className="text-sm font-medium text-foreground">{row.label}</span>
363
- </div>
364
- </TableCell>
415
+ <TableCell className="px-4 py-3">
416
+ <PrimaryMetricCell
417
+ row={row}
418
+ displayMode={primaryMetricDisplayMode}
419
+ />
420
+ </TableCell>
365
421
 
366
- <TableCell className="px-4 py-3">
367
- <div className="flex items-center gap-2">
368
- <span className="shrink-0">{progress.icon}</span>
369
- <div className="w-full max-w-[180px]">
370
- <div className="mb-1 text-sm font-bold text-foreground">
371
- {row.primaryValue}/{row.primaryTarget}
372
- </div>
373
- <div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
374
- <div
375
- className={cn("h-full rounded-full", progress.color)}
376
- style={{ width: `${Math.min(100, percentage)}%` }}
377
- />
378
- </div>
379
- <div className={cn("mt-1 text-xs font-medium", progress.textColor)}>
380
- {Math.round(percentage)}%
381
- </div>
382
- </div>
383
- </div>
384
- </TableCell>
385
-
386
- <TableCell className="px-4 py-3 text-right text-sm font-semibold text-emerald-600">
387
- {row.ratePercent}%
388
- </TableCell>
389
- <TableCell className="px-4 py-3 text-right text-sm font-medium text-foreground">
390
- {row.metricOne}
391
- </TableCell>
392
- <TableCell className="px-4 py-3 text-right text-sm text-muted-foreground">
393
- {row.metricTwo}
394
- </TableCell>
395
- <TableCell className="px-4 py-3 text-right text-sm font-medium text-foreground">
396
- {row.metricThree}
397
- </TableCell>
398
- <TableCell className="px-4 py-3 text-right text-sm font-medium text-foreground">
399
- {row.metricFour}
400
- </TableCell>
401
- </TableRow>
402
- )
403
- })}
422
+ <TableCell className="px-4 py-3 text-right text-sm font-semibold text-emerald-600">
423
+ {row.ratePercent}%
424
+ </TableCell>
425
+ <TableCell className="px-4 py-3 text-right text-sm font-medium text-foreground">
426
+ {row.metricOne}
427
+ </TableCell>
428
+ <TableCell className="px-4 py-3 text-right text-sm text-muted-foreground">
429
+ {row.metricTwo}
430
+ </TableCell>
431
+ <TableCell className="px-4 py-3 text-right text-sm font-medium text-foreground">
432
+ {row.metricThree}
433
+ </TableCell>
434
+ <TableCell className="px-4 py-3 text-right text-sm font-medium text-foreground">
435
+ {row.metricFour}
436
+ </TableCell>
437
+ </TableRow>
438
+ ))}
404
439
  </TableBody>
405
440
  </Table>
406
441
  </div>
@@ -410,7 +445,8 @@ export function PerformanceMetricsTable({
410
445
  <div className="flex items-center justify-between border-t border-border bg-muted/20 px-4 py-3">
411
446
  <span className="text-xs text-muted-foreground">
412
447
  Showing {sortedRows.length === 0 ? 0 : start + 1} to{" "}
413
- {Math.min(start + pageSize, sortedRows.length)} of {sortedRows.length} rows
448
+ {Math.min(start + pageSize, sortedRows.length)} of {sortedRows.length}{" "}
449
+ rows
414
450
  </span>
415
451
  <div className="flex items-center gap-2">
416
452
  <Button
@@ -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>