@handled-ai/design-system 0.18.6 → 0.18.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -53,6 +53,7 @@ import type {
53
53
  InboxDetailSections,
54
54
  SignalScoreData,
55
55
  BriefStyleVariant,
56
+ TimelineSystemEventsConfig,
56
57
  } from "./prototype-config"
57
58
 
58
59
  // ---------------------------------------------------------------------------
@@ -150,8 +151,151 @@ export interface DetailViewProps {
150
151
  onRequestApproval?: () => Promise<void>
151
152
  /** Number of important/attention-worthy events to highlight on the collapsed timeline header. */
152
153
  attentionCount?: number
154
+ /** Configuration for the system-noise events toggle (score changes, etc.). */
155
+ timelineSystemEventsConfig?: TimelineSystemEventsConfig
156
+
157
+ // ── Deprecated individual props (use timelineSystemEventsConfig instead) ──
158
+ /** @deprecated Use `timelineSystemEventsConfig.toggleLabel`. */
159
+ timelineSystemEventsToggleLabel?: string
160
+ /** @deprecated Use `timelineSystemEventsConfig.storageKey`. */
161
+ timelineSystemEventsStorageKey?: string
162
+ /** @deprecated Use `timelineSystemEventsConfig.defaultVisible`. */
163
+ timelineSystemEventsDefaultVisible?: boolean
164
+ /** @deprecated Use `timelineSystemEventsConfig.hiddenHint`. */
165
+ timelineSystemEventsHiddenHint?: string
166
+ /** @deprecated Use `timelineSystemEventsConfig.visibleHint`. */
167
+ timelineSystemEventsVisibleHint?: string
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // TimelineSection — extracted from the IIFE in DetailView for readability
172
+ // ---------------------------------------------------------------------------
173
+
174
+ function TimelineSection({
175
+ timelineEvents,
176
+ showTimeline,
177
+ setShowTimeline,
178
+ showSystemEvents,
179
+ setShowSystemEvents,
180
+ attentionCount,
181
+ sysEvtConfig,
182
+ lastActivityTime,
183
+ }: {
184
+ timelineEvents: TimelineEvent[]
185
+ showTimeline: boolean
186
+ setShowTimeline: React.Dispatch<React.SetStateAction<boolean>>
187
+ showSystemEvents: boolean
188
+ setShowSystemEvents: React.Dispatch<React.SetStateAction<boolean>>
189
+ attentionCount?: number
190
+ sysEvtConfig?: TimelineSystemEventsConfig
191
+ lastActivityTime?: string
192
+ }) {
193
+ // Single-pass partition: compute visibleEvents and hiddenCount together
194
+ const visibleEvents: TimelineEvent[] = []
195
+ let hiddenCount = 0
196
+ for (const e of timelineEvents) {
197
+ if (e.isSystemNoise) hiddenCount++
198
+ if (!e.isSystemNoise || showSystemEvents) visibleEvents.push(e)
199
+ }
200
+ const hasSystemNoise = hiddenCount > 0
201
+
202
+ // The toggle renders whenever there are system-noise events — even if no
203
+ // config was provided — so consumers that emit `isSystemNoise: true` always
204
+ // give users a way to reveal those events.
205
+ const toggleLabel = sysEvtConfig?.toggleLabel ?? "System events"
206
+
207
+ // Derive "Last activity" from the first *visible* event so the collapsed
208
+ // header never points at a hidden score-update. The caller-supplied
209
+ // `lastActivityTime` is only used when system-noise filtering is NOT active
210
+ // (i.e. all events are visible) since it may come from an unfiltered source
211
+ // such as `case.last_activity_at`.
212
+ const firstVisibleTime =
213
+ (!hasSystemNoise || showSystemEvents) && lastActivityTime
214
+ ? lastActivityTime
215
+ : visibleEvents.length > 0
216
+ ? visibleEvents[0].time
217
+ : ""
218
+
219
+ const visibleCount = visibleEvents.length
220
+ const eventCountLabel = `${visibleCount} ${visibleCount === 1 ? "event" : "events"}`
221
+
222
+ return (
223
+ <div className="mb-8">
224
+ {/* Header — outer non-interactive container */}
225
+ <div
226
+ className="group/timeline flex w-full items-center justify-between gap-2 py-2 rounded-md transition-colors hover:bg-muted/40 -mx-2 px-2"
227
+ data-testid="timeline-header"
228
+ >
229
+ {/* Left: collapse/expand button */}
230
+ <button
231
+ type="button"
232
+ onClick={() => setShowTimeline((prev) => !prev)}
233
+ className="flex items-center gap-2 cursor-pointer bg-transparent border-0 p-0"
234
+ data-testid="timeline-collapse-btn"
235
+ >
236
+ <h3 className="text-xs font-bold text-muted-foreground uppercase tracking-wider group-hover/timeline:text-foreground transition-colors">Activity timeline</h3>
237
+ {!showTimeline && attentionCount != null && attentionCount > 0 && (
238
+ <span className="inline-flex items-center gap-1 rounded-full bg-destructive/10 px-1.5 py-0.5 text-[10px] font-semibold text-destructive border border-destructive/20">
239
+ {attentionCount} new
240
+ </span>
241
+ )}
242
+ {!showTimeline && firstVisibleTime && (
243
+ <span className="text-[11px] text-muted-foreground/60" data-testid="last-activity-hint">
244
+ &middot; Last activity {firstVisibleTime}
245
+ </span>
246
+ )}
247
+ <div className="flex items-center gap-1.5">
248
+ <span className="text-[11px] font-medium text-muted-foreground" data-testid="event-count">{eventCountLabel}</span>
249
+ <ChevronDown className={`h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${showTimeline ? "rotate-180" : ""}`} />
250
+ </div>
251
+ </button>
252
+
253
+ {/* Right: system-events toggle — always rendered when noise events exist */}
254
+ {hasSystemNoise && (
255
+ <button
256
+ type="button"
257
+ onClick={() => setShowSystemEvents((prev) => !prev)}
258
+ className="flex shrink-0 items-center gap-1.5 rounded-full border border-border bg-background px-2.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground cursor-pointer"
259
+ aria-pressed={showSystemEvents}
260
+ data-testid="system-events-toggle"
261
+ >
262
+ {toggleLabel}
263
+ <span
264
+ className="inline-flex items-center justify-center rounded-full bg-muted px-1.5 text-[10px] font-semibold min-w-[18px] tabular-nums"
265
+ data-testid="hidden-count-badge"
266
+ >
267
+ {hiddenCount}
268
+ </span>
269
+ </button>
270
+ )}
271
+ </div>
272
+
273
+ {/* Timeline body */}
274
+ {showTimeline && visibleEvents.length > 0 && (
275
+ <div className="mt-3">
276
+ <TimelineActivity events={visibleEvents} />
277
+ </div>
278
+ )}
279
+
280
+ {/* Footer hint */}
281
+ {showTimeline && !showSystemEvents && sysEvtConfig?.hiddenHint && hasSystemNoise && (
282
+ <p className="mt-2 text-[11px] text-muted-foreground/60 border-t border-dashed border-border pt-2" data-testid="timeline-footer-hint">
283
+ {sysEvtConfig.hiddenHint}
284
+ </p>
285
+ )}
286
+ {showTimeline && showSystemEvents && sysEvtConfig?.visibleHint && hasSystemNoise && (
287
+ <p className="mt-2 text-[11px] text-muted-foreground/60 border-t border-dashed border-border pt-2" data-testid="timeline-footer-hint">
288
+ {sysEvtConfig.visibleHint.replace("{count}", String(hiddenCount))}
289
+ </p>
290
+ )}
291
+ </div>
292
+ )
153
293
  }
154
294
 
295
+ // ---------------------------------------------------------------------------
296
+ // Detail View
297
+ // ---------------------------------------------------------------------------
298
+
155
299
  export function DetailView({
156
300
  item,
157
301
  sections,
@@ -184,10 +328,79 @@ export function DetailView({
184
328
  opportunityPreview,
185
329
  onRequestApproval,
186
330
  attentionCount,
331
+ timelineSystemEventsConfig: configProp,
332
+ timelineSystemEventsToggleLabel,
333
+ timelineSystemEventsStorageKey,
334
+ timelineSystemEventsDefaultVisible,
335
+ timelineSystemEventsHiddenHint,
336
+ timelineSystemEventsVisibleHint,
187
337
  }: DetailViewProps) {
338
+ // Resolve system-events config: prefer the config object, fall back to deprecated individual props.
339
+ const sysEvtConfig = React.useMemo<TimelineSystemEventsConfig | undefined>(() => {
340
+ if (configProp) return configProp
341
+ // Build from deprecated individual props if any are provided
342
+ if (
343
+ timelineSystemEventsToggleLabel ||
344
+ timelineSystemEventsStorageKey ||
345
+ timelineSystemEventsDefaultVisible !== undefined ||
346
+ timelineSystemEventsHiddenHint ||
347
+ timelineSystemEventsVisibleHint
348
+ ) {
349
+ return {
350
+ toggleLabel: timelineSystemEventsToggleLabel,
351
+ storageKey: timelineSystemEventsStorageKey,
352
+ defaultVisible: timelineSystemEventsDefaultVisible,
353
+ hiddenHint: timelineSystemEventsHiddenHint,
354
+ visibleHint: timelineSystemEventsVisibleHint,
355
+ }
356
+ }
357
+ return undefined
358
+ }, [
359
+ configProp,
360
+ timelineSystemEventsToggleLabel,
361
+ timelineSystemEventsStorageKey,
362
+ timelineSystemEventsDefaultVisible,
363
+ timelineSystemEventsHiddenHint,
364
+ timelineSystemEventsVisibleHint,
365
+ ])
366
+
188
367
  const [showTimeline, setShowTimeline] = React.useState(false)
189
368
  const [extraActions, setExtraActions] = React.useState<SuggestedAction[]>([])
190
369
 
370
+ // ---- System-noise toggle state ----
371
+ const sysEvtDefaultVisible = sysEvtConfig?.defaultVisible ?? false
372
+ const sysEvtStorageKey = sysEvtConfig?.storageKey
373
+ const [showSystemEvents, setShowSystemEvents] = React.useState(sysEvtDefaultVisible)
374
+ const initialReadDoneRef = React.useRef(false)
375
+
376
+ // Read persisted value from localStorage on mount
377
+ React.useEffect(() => {
378
+ if (!sysEvtStorageKey) {
379
+ initialReadDoneRef.current = true
380
+ return
381
+ }
382
+ try {
383
+ const stored = localStorage.getItem(sysEvtStorageKey)
384
+ if (stored !== null) {
385
+ setShowSystemEvents(stored === "true")
386
+ }
387
+ } catch {
388
+ // localStorage unavailable — ignore
389
+ }
390
+ initialReadDoneRef.current = true
391
+ }, [sysEvtStorageKey])
392
+
393
+ // Write to localStorage when the toggle changes (skip initial if matching default)
394
+ React.useEffect(() => {
395
+ if (!sysEvtStorageKey) return
396
+ if (!initialReadDoneRef.current) return
397
+ try {
398
+ localStorage.setItem(sysEvtStorageKey, String(showSystemEvents))
399
+ } catch {
400
+ // localStorage unavailable — ignore
401
+ }
402
+ }, [showSystemEvents, sysEvtStorageKey])
403
+
191
404
  React.useEffect(() => {
192
405
  setShowTimeline(false)
193
406
  setExtraActions([])
@@ -355,36 +568,16 @@ export function DetailView({
355
568
 
356
569
  {/* Activity Timeline */}
357
570
  {sections.timeline && timelineEvents.length > 0 && (
358
- <div className="mb-8">
359
- <button
360
- type="button"
361
- onClick={() => setShowTimeline((prev) => !prev)}
362
- className="group/timeline flex w-full items-center justify-between gap-2 py-2 rounded-md transition-colors hover:bg-muted/40 -mx-2 px-2 cursor-pointer"
363
- >
364
- <div className="flex items-center gap-2">
365
- <h3 className="text-xs font-bold text-muted-foreground uppercase tracking-wider group-hover/timeline:text-foreground transition-colors">Activity timeline</h3>
366
- {!showTimeline && attentionCount != null && attentionCount > 0 && (
367
- <span className="inline-flex items-center gap-1 rounded-full bg-destructive/10 px-1.5 py-0.5 text-[10px] font-semibold text-destructive border border-destructive/20">
368
- {attentionCount} new
369
- </span>
370
- )}
371
- {!showTimeline && (lastActivityTime || (timelineEvents.length > 0 && timelineEvents[0].time)) && (
372
- <span className="text-[11px] text-muted-foreground/60">
373
- &middot; Last activity {lastActivityTime ?? timelineEvents[0]?.time ?? ''}
374
- </span>
375
- )}
376
- </div>
377
- <div className="flex items-center gap-1.5">
378
- <span className="text-[11px] font-medium text-muted-foreground">{timelineEvents.length} events</span>
379
- <ChevronDown className={`h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${showTimeline ? "rotate-180" : ""}`} />
380
- </div>
381
- </button>
382
- {showTimeline && (
383
- <div className="mt-3">
384
- <TimelineActivity events={timelineEvents} />
385
- </div>
386
- )}
387
- </div>
571
+ <TimelineSection
572
+ timelineEvents={timelineEvents}
573
+ showTimeline={showTimeline}
574
+ setShowTimeline={setShowTimeline}
575
+ showSystemEvents={showSystemEvents}
576
+ setShowSystemEvents={setShowSystemEvents}
577
+ attentionCount={attentionCount}
578
+ sysEvtConfig={sysEvtConfig}
579
+ lastActivityTime={lastActivityTime}
580
+ />
388
581
  )}
389
582
  </div>
390
583
 
@@ -453,6 +646,8 @@ export function PrototypeInboxView({
453
646
  renderBeforeScore,
454
647
  renderAfterScore,
455
648
  lastActivityTime,
649
+ timelineSystemEventsConfig,
650
+ attentionCount,
456
651
  renderTitleExtra,
457
652
  renderTitleSubtext,
458
653
  sortOptions,
@@ -693,6 +888,8 @@ export function PrototypeInboxView({
693
888
  renderBeforeScore,
694
889
  renderAfterScore,
695
890
  lastActivityTime,
891
+ timelineSystemEventsConfig,
892
+ attentionCount,
696
893
  renderTitleExtra,
697
894
  renderTitleSubtext,
698
895
  onOpenSignalBucket,