@handled-ai/design-system 0.20.29 → 0.20.30

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 (34) hide show
  1. package/dist/components/account-contacts-popover.d.ts +1 -1
  2. package/dist/components/account-contacts-popover.js +20 -25
  3. package/dist/components/account-contacts-popover.js.map +1 -1
  4. package/dist/components/email-recipient-field.d.ts +1 -9
  5. package/dist/components/email-recipient-field.js +4 -30
  6. package/dist/components/email-recipient-field.js.map +1 -1
  7. package/dist/components/score-why-chips.d.ts +1 -1
  8. package/dist/components/signal-priority-popover.d.ts +1 -1
  9. package/dist/components/suggested-actions.d.ts +1 -2
  10. package/dist/components/suggested-actions.js.map +1 -1
  11. package/dist/components/timeline-activity.js +41 -51
  12. package/dist/components/timeline-activity.js.map +1 -1
  13. package/dist/index.d.ts +1 -1
  14. package/dist/prototype/index.d.ts +1 -1
  15. package/dist/prototype/prototype-accounts-view.d.ts +1 -1
  16. package/dist/prototype/prototype-admin-view.d.ts +1 -1
  17. package/dist/prototype/prototype-config.d.ts +1 -1
  18. package/dist/prototype/prototype-inbox-view.d.ts +10 -3
  19. package/dist/prototype/prototype-inbox-view.js +10 -4
  20. package/dist/prototype/prototype-inbox-view.js.map +1 -1
  21. package/dist/prototype/prototype-insights-view.d.ts +1 -1
  22. package/dist/prototype/prototype-shell.d.ts +1 -1
  23. package/dist/{signal-priority-popover-BJHd07dU.d.ts → signal-priority-popover-DIUVhipw.d.ts} +17 -1
  24. package/package.json +1 -1
  25. package/src/components/__tests__/account-contacts-popover.test.tsx +0 -71
  26. package/src/components/__tests__/email-recipient-field.test.tsx +0 -123
  27. package/src/components/__tests__/timeline-activity.test.tsx +0 -16
  28. package/src/components/account-contacts-popover.tsx +19 -33
  29. package/src/components/email-recipient-field.tsx +0 -45
  30. package/src/components/suggested-actions.tsx +3 -4
  31. package/src/components/timeline-activity.tsx +1 -7
  32. package/src/prototype/__tests__/detail-view-timeline-system-events.test.tsx +81 -0
  33. package/src/prototype/prototype-config.ts +17 -0
  34. package/src/prototype/prototype-inbox-view.tsx +16 -1
@@ -513,127 +513,4 @@ describe("EmailRecipientField", () => {
513
513
  // trigger another search.
514
514
  expect(secondOnSearch).not.toHaveBeenCalled()
515
515
  })
516
-
517
- // --- Last activity (WIT-1007) ---
518
-
519
- const activityContact: SuggestedContact = {
520
- name: "Cara Customer",
521
- role: "VP Ops",
522
- email: "cara@example.com",
523
- confirmed: true,
524
- lastActivity: { date: "Jun 8, 2026", type: "email", timelineEventId: "evt-1" },
525
- }
526
-
527
- it("renders last activity as non-clickable text when no onOpenRecentActivity callback is provided", () => {
528
- render(
529
- <EmailRecipientField
530
- label="To"
531
- recipients={[]}
532
- onRecipientsChange={vi.fn()}
533
- showPicker
534
- contacts={[activityContact]}
535
- />,
536
- )
537
-
538
- fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
539
- expect(screen.getByText("Jun 8, 2026")).toBeTruthy()
540
- // No clickable activity button exists.
541
- expect(screen.queryByRole("button", { name: /Last activity/i })).toBeNull()
542
- })
543
-
544
- it("renders last activity as non-clickable text when timelineEventId is missing even with a callback", () => {
545
- const onOpenRecentActivity = vi.fn()
546
- render(
547
- <EmailRecipientField
548
- label="To"
549
- recipients={[]}
550
- onRecipientsChange={vi.fn()}
551
- showPicker
552
- contacts={[
553
- {
554
- ...activityContact,
555
- lastActivity: { date: "Jun 8, 2026", type: "email" },
556
- },
557
- ]}
558
- onOpenRecentActivity={onOpenRecentActivity}
559
- />,
560
- )
561
-
562
- fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
563
- expect(screen.getByText("Jun 8, 2026")).toBeTruthy()
564
- expect(screen.queryByRole("button", { name: /Last activity/i })).toBeNull()
565
- })
566
-
567
- it("renders last activity as a clickable button only when callback and timelineEventId both exist", () => {
568
- const onOpenRecentActivity = vi.fn()
569
- render(
570
- <EmailRecipientField
571
- label="To"
572
- recipients={[]}
573
- onRecipientsChange={vi.fn()}
574
- showPicker
575
- contacts={[activityContact]}
576
- onOpenRecentActivity={onOpenRecentActivity}
577
- />,
578
- )
579
-
580
- fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
581
- const activityButton = screen.getByRole("button", { name: /Last activity/i })
582
- expect(activityButton.tagName).toBe("BUTTON")
583
- })
584
-
585
- it("invokes onOpenRecentActivity with the contact and does NOT add a recipient on activity click", () => {
586
- const onOpenRecentActivity = vi.fn()
587
- const onChange = vi.fn()
588
- render(
589
- <EmailRecipientField
590
- label="To"
591
- recipients={[]}
592
- onRecipientsChange={onChange}
593
- showPicker
594
- contacts={[activityContact]}
595
- onOpenRecentActivity={onOpenRecentActivity}
596
- />,
597
- )
598
-
599
- fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
600
- fireEvent.click(screen.getByRole("button", { name: /Last activity/i }))
601
-
602
- expect(onOpenRecentActivity).toHaveBeenCalledTimes(1)
603
- expect(onOpenRecentActivity).toHaveBeenCalledWith(activityContact)
604
- // Clicking the activity must not select/add the recipient.
605
- expect(onChange).not.toHaveBeenCalled()
606
- })
607
-
608
- it("keeps the last-activity button clickable for already-added contacts without adding the recipient", () => {
609
- const onOpenRecentActivity = vi.fn()
610
- const onChange = vi.fn()
611
- render(
612
- <EmailRecipientField
613
- label="To"
614
- recipients={[]}
615
- onRecipientsChange={onChange}
616
- showPicker
617
- contacts={[activityContact]}
618
- addedEmails={new Set([activityContact.email!.toLowerCase()])}
619
- onOpenRecentActivity={onOpenRecentActivity}
620
- />,
621
- )
622
-
623
- fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
624
-
625
- // The row is disabled (already added) but its activity button stays clickable.
626
- const option = screen
627
- .getAllByRole("option")
628
- .find((o) => o.textContent?.includes("Cara Customer"))!
629
- expect(option.className).toContain("pointer-events-none")
630
- const activityButton = within(option).getByRole("button", { name: /Last activity/i })
631
- expect(activityButton.className).toContain("pointer-events-auto")
632
-
633
- fireEvent.click(activityButton)
634
-
635
- expect(onOpenRecentActivity).toHaveBeenCalledTimes(1)
636
- expect(onOpenRecentActivity).toHaveBeenCalledWith(activityContact)
637
- expect(onChange).not.toHaveBeenCalled()
638
- })
639
516
  })
@@ -44,22 +44,6 @@ describe("TimelineActivity", () => {
44
44
  expect(container.firstElementChild).toHaveAttribute("data-variant", "default")
45
45
  })
46
46
 
47
- it("renders each timeline item wrapper with scroll/focus anchors and test ids", () => {
48
- const events = [minimal({ id: "evt-a" }), minimal({ id: "evt-b" })]
49
- const { container } = render(<TimelineActivity events={events} />)
50
-
51
- for (const id of ["evt-a", "evt-b"]) {
52
- const wrapper = container.querySelector(`#timeline-event-${id}`)
53
- expect(wrapper).not.toBeNull()
54
- expect(wrapper).toHaveAttribute("data-testid", `timeline-event-${id}`)
55
- expect(wrapper).toHaveAttribute("data-activity-id", id)
56
- expect(wrapper).toHaveAttribute("tabindex", "-1")
57
- }
58
-
59
- // The test id is also reachable via screen for click-through targeting.
60
- expect(screen.getByTestId("timeline-event-evt-a")).toBeTruthy()
61
- })
62
-
63
47
  it("marks the root and interactive cards with the case-panel variant", () => {
64
48
  const event = minimal({
65
49
  isInteractive: true,
@@ -5,7 +5,6 @@ import {
5
5
  Clock,
6
6
  ExternalLink,
7
7
  } from "lucide-react"
8
- import { cn } from "../lib/utils"
9
8
  import type { SuggestedContact, SuggestedActionsIconMap } from "./suggested-actions"
10
9
 
11
10
  // ---------------------------------------------------------------------------
@@ -38,7 +37,7 @@ export interface AccountContactsPopoverProps {
38
37
  /** Optional replacement-selection callback. When provided, row clicks call this instead of additive onSelect/onSelectTo. */
39
38
  onSelectSwitch?: (contact: SuggestedContact) => void
40
39
  onViewAll?: () => void
41
- onOpenRecentActivity?: (contact: SuggestedContact) => void
40
+ onOpenRecentActivity?: () => void
42
41
  trigger: React.ReactNode
43
42
  iconMap?: SuggestedActionsIconMap
44
43
  }
@@ -118,37 +117,24 @@ export function AccountContactsPopover({
118
117
  <div className="truncate text-xs text-muted-foreground leading-tight">
119
118
  {c.role} · {c.email ?? c.emails?.[0] ?? c.phone ?? c.phones?.[0] ?? ""}
120
119
  </div>
121
- {c.lastActivity && (() => {
122
- const activityContent = (
123
- <>
124
- <Clock className="w-3 h-3 shrink-0" />
125
- <span className="shrink-0 font-medium">Last activity</span>
126
- <span className="shrink-0 text-muted-foreground/60">·</span>
127
- <span className="shrink-0">{c.lastActivity.date}</span>
128
- <span className="shrink-0 text-muted-foreground/60">·</span>
129
- <span className="truncate capitalize">{c.lastActivity.type}</span>
130
- </>
131
- )
132
- const chipBaseClass =
133
- "mt-1.5 flex max-w-full items-center gap-1.5 overflow-hidden rounded-md border border-border/70 bg-muted/30 px-2 py-1 text-[11px] text-muted-foreground"
134
- return onOpenRecentActivity && c.lastActivity.timelineEventId ? (
135
- <button
136
- type="button"
137
- onClick={(e) => {
138
- e.stopPropagation()
139
- onOpenRecentActivity(c)
140
- setOpen(false)
141
- }}
142
- className={cn(chipBaseClass, "hover:text-foreground hover:bg-muted/50 transition-colors")}
143
- >
144
- {activityContent}
145
- </button>
146
- ) : (
147
- <div className={chipBaseClass}>
148
- {activityContent}
149
- </div>
150
- )
151
- })()}
120
+ {c.lastActivity && (
121
+ <button
122
+ type="button"
123
+ onClick={(e) => {
124
+ e.stopPropagation()
125
+ onOpenRecentActivity?.()
126
+ setOpen(false)
127
+ }}
128
+ className="mt-1.5 flex max-w-full items-center gap-1.5 overflow-hidden rounded-md border border-border/70 bg-muted/30 px-2 py-1 text-[11px] text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
129
+ >
130
+ <Clock className="w-3 h-3 shrink-0" />
131
+ <span className="shrink-0 font-medium">Last activity</span>
132
+ <span className="shrink-0 text-muted-foreground/60">·</span>
133
+ <span className="shrink-0">{c.lastActivity.date}</span>
134
+ <span className="shrink-0 text-muted-foreground/60">·</span>
135
+ <span className="truncate capitalize">{c.lastActivity.type}</span>
136
+ </button>
137
+ )}
152
138
  </div>
153
139
  <div className="ml-2 flex items-center gap-1.5 shrink-0">
154
140
  {resolvedDefaultSelectLabel && (
@@ -4,7 +4,6 @@ import * as React from "react"
4
4
  import { Popover as PopoverPrimitive } from "radix-ui"
5
5
  import {
6
6
  ChevronDown,
7
- Clock,
8
7
  CornerDownLeft,
9
8
  Plus,
10
9
  Search,
@@ -64,14 +63,6 @@ export interface EmailRecipientFieldProps {
64
63
  onSearch?: (query: string) => void
65
64
  /** Shows a "Searching contacts..." indicator while async results load. */
66
65
  searchLoading?: boolean
67
- /**
68
- * Opens the recent activity for a contact in the Account Panel timeline.
69
- * When provided AND the contact's `lastActivity.timelineEventId` exists, the
70
- * last-activity line renders as a button that calls this callback (and stops
71
- * propagation so it never adds/selects the recipient). Otherwise the line
72
- * renders as non-clickable text.
73
- */
74
- onOpenRecentActivity?: (contact: SuggestedContact) => void
75
66
  }
76
67
 
77
68
  function RecipientChipPill({
@@ -155,7 +146,6 @@ function ContactPickerContents({
155
146
  onAddEmail,
156
147
  onSearch,
157
148
  searchLoading = false,
158
- onOpenRecentActivity,
159
149
  }: {
160
150
  contacts: SuggestedContact[]
161
151
  addedEmails: Set<string>
@@ -163,7 +153,6 @@ function ContactPickerContents({
163
153
  onAddEmail: (email: string) => void
164
154
  onSearch?: (query: string) => void
165
155
  searchLoading?: boolean
166
- onOpenRecentActivity?: (contact: SuggestedContact) => void
167
156
  }) {
168
157
  const [query, setQuery] = React.useState("")
169
158
 
@@ -290,38 +279,6 @@ function ContactPickerContents({
290
279
  {email}
291
280
  </div>
292
281
  ) : null}
293
- {contact.lastActivity ? (() => {
294
- const activityContent = (
295
- <>
296
- <Clock className="size-3 shrink-0" />
297
- <span className="shrink-0">Last activity</span>
298
- <span className="shrink-0 text-muted-foreground/60">·</span>
299
- <span className="shrink-0">{contact.lastActivity.date}</span>
300
- <span className="shrink-0 text-muted-foreground/60">·</span>
301
- <span className="truncate capitalize">{contact.lastActivity.type}</span>
302
- </>
303
- )
304
- return onOpenRecentActivity && contact.lastActivity.timelineEventId ? (
305
- <button
306
- type="button"
307
- onClick={(event) => {
308
- event.stopPropagation()
309
- onOpenRecentActivity(contact)
310
- }}
311
- // `pointer-events-auto` keeps the activity button clickable even
312
- // when the parent row is disabled (no email / already added) and
313
- // gets `pointer-events-none` — the timeline activity is still
314
- // openable for those contacts.
315
- className="mt-1 flex max-w-full items-center gap-1 overflow-hidden text-[11px] text-muted-foreground hover:text-foreground transition-colors pointer-events-auto"
316
- >
317
- {activityContent}
318
- </button>
319
- ) : (
320
- <div className="mt-1 flex max-w-full items-center gap-1 overflow-hidden text-[11px] text-muted-foreground">
321
- {activityContent}
322
- </div>
323
- )
324
- })() : null}
325
282
  </div>
326
283
  {alreadyAdded ? (
327
284
  <span className="shrink-0 text-[10.5px] font-medium text-muted-foreground">
@@ -361,7 +318,6 @@ export function EmailRecipientField({
361
318
  contactToRecipient,
362
319
  onSearch,
363
320
  searchLoading,
364
- onOpenRecentActivity,
365
321
  }: EmailRecipientFieldProps) {
366
322
  const [value, setValue] = React.useState("")
367
323
  const [pickerOpen, setPickerOpen] = React.useState(false)
@@ -509,7 +465,6 @@ export function EmailRecipientField({
509
465
  }}
510
466
  onSearch={onSearch}
511
467
  searchLoading={searchLoading}
512
- onOpenRecentActivity={onOpenRecentActivity}
513
468
  />
514
469
  </PopoverPrimitive.Content>
515
470
  </PopoverPrimitive.Portal>
@@ -84,7 +84,6 @@ export interface SuggestedContact {
84
84
  lastActivity?: {
85
85
  date: string
86
86
  type: string
87
- timelineEventId?: string
88
87
  }
89
88
  }
90
89
 
@@ -548,7 +547,7 @@ function EmailHeader({
548
547
  onBccAdd?: (contact: SuggestedContact) => void
549
548
  onBccRemove?: (index: number) => void
550
549
  onOpenAccountDetails?: () => void
551
- onOpenRecentActivity?: (contact: SuggestedContact) => void
550
+ onOpenRecentActivity?: () => void
552
551
  iconMap?: SuggestedActionsIconMap
553
552
  showSubject?: boolean
554
553
  accountDetailsLabel?: string
@@ -911,7 +910,7 @@ function SuggestedActionCard({
911
910
  signature?: string | React.ReactNode
912
911
  onDuplicate?: (id: number | string) => void
913
912
  onOpenAccountDetails?: () => void
914
- onOpenRecentActivity?: (contact: SuggestedContact) => void
913
+ onOpenRecentActivity?: () => void
915
914
  onMarkComplete?: (id: number | string) => void
916
915
  onDispatchAgent?: (id: number | string, editedContent?: string, settings?: { aiDisclosureEnabled?: boolean; maxDurationMinutes?: string; callRecordingEnabled?: boolean; recordingNoticeEnabled?: boolean }) => void
917
916
  onFeedback?: (type: "up" | "down", pills: string[], detail: string) => void
@@ -1572,7 +1571,7 @@ export interface SuggestedActionsProps {
1572
1571
  signature?: string | React.ReactNode
1573
1572
  onDuplicate?: (id: number | string) => void
1574
1573
  onOpenAccountDetails?: () => void
1575
- onOpenRecentActivity?: (contact: SuggestedContact) => void
1574
+ onOpenRecentActivity?: () => void
1576
1575
  onMarkComplete?: (id: number | string) => void
1577
1576
  onDispatchAgent?: (id: number | string, editedContent?: string, settings?: { aiDisclosureEnabled?: boolean; maxDurationMinutes?: string; callRecordingEnabled?: boolean; recordingNoticeEnabled?: boolean }) => void
1578
1577
  iconMap?: SuggestedActionsIconMap
@@ -298,13 +298,7 @@ function TimelineItem({
298
298
  const iconClasses = toneStyle ? toneStyle.icon : NEUTRAL_ICON_CLASSES
299
299
 
300
300
  return (
301
- <div
302
- id={`timeline-event-${event.id}`}
303
- data-testid={`timeline-event-${event.id}`}
304
- data-activity-id={event.id}
305
- tabIndex={-1}
306
- className={classes.outerRowGap}
307
- >
301
+ <div className={classes.outerRowGap}>
308
302
  {!isLast && (
309
303
  <div className={classes.connector} />
310
304
  )}
@@ -475,4 +475,85 @@ describe("DetailView timeline system-events toggle", () => {
475
475
  expect(hint).toBeNull()
476
476
  expect(toggle).toHaveAttribute("title", "Legacy hidden hint.")
477
477
  })
478
+
479
+ // --- renderTimelineHeaderControls slot (WIT-1008 Task 1) ---
480
+
481
+ it("renders custom timeline header controls without interfering with system-event ownership (case-panel)", () => {
482
+ const props = baseProps({
483
+ sectionLayout: "case-panel-v2",
484
+ renderTimelineHeaderControls: ({ visibleCount, hiddenCount }) => (
485
+ <button type="button" data-testid="custom-filter">
486
+ Custom Filter ({visibleCount}/{hiddenCount})
487
+ </button>
488
+ ),
489
+ })
490
+ const { container, getByTestId } = render(<DetailView {...props} />)
491
+
492
+ // Custom node renders in the timeline header.
493
+ const header = container.querySelector('[data-testid="timeline-header"]') as HTMLElement
494
+ const custom = getByTestId("custom-filter")
495
+ expect(custom).not.toBeNull()
496
+ expect(header.contains(custom)).toBe(true)
497
+ expect(custom.textContent).toContain("Custom Filter")
498
+
499
+ // System-noise events remain hidden by default.
500
+ expandTimeline(container)
501
+ const eventCount = container.querySelector('[data-testid="event-count"]')
502
+ expect(eventCount?.textContent).toBe("2 events")
503
+ expect(container.textContent).not.toContain("Score updated +3")
504
+ expect(container.textContent).not.toContain("Score updated -1")
505
+
506
+ // They only appear after clicking the existing system-events toggle.
507
+ const toggle = container.querySelector('[data-testid="system-events-toggle"]') as HTMLElement
508
+ fireEvent.click(toggle)
509
+ expect(container.textContent).toContain("Score updated +3")
510
+ expect(container.querySelector('[data-testid="event-count"]')?.textContent).toBe("4 events")
511
+ })
512
+
513
+ it("renders custom timeline header controls in the default layout, before the system-events toggle, without owning system-event state", () => {
514
+ // Capture the args the render prop receives across renders.
515
+ const seenArgs: Array<{ visibleCount: number; hiddenCount: number; showSystemEvents: boolean }> = []
516
+ const props = baseProps({
517
+ // default layout (no sectionLayout="case-panel-v2")
518
+ renderTimelineHeaderControls: (args) => {
519
+ seenArgs.push(args)
520
+ return (
521
+ <button type="button" data-testid="custom-filter">
522
+ Custom Filter
523
+ </button>
524
+ )
525
+ },
526
+ })
527
+ const { container, getByTestId } = render(<DetailView {...props} />)
528
+
529
+ const header = container.querySelector('[data-testid="timeline-header"]') as HTMLElement
530
+ const custom = getByTestId("custom-filter")
531
+ const toggle = container.querySelector('[data-testid="system-events-toggle"]') as HTMLElement
532
+
533
+ // Custom node renders inside the timeline header.
534
+ expect(header.contains(custom)).toBe(true)
535
+
536
+ // Custom control appears BEFORE the system-events toggle in DOM order.
537
+ const order = custom.compareDocumentPosition(toggle)
538
+ expect(order & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
539
+
540
+ // Expand the timeline; system-noise events are hidden by default.
541
+ expandTimeline(container)
542
+ expect(container.textContent).toContain("Email sent")
543
+ expect(container.textContent).not.toContain("Score updated +3")
544
+
545
+ // Clicking the custom control does NOT collapse/expand the timeline and
546
+ // does NOT change system-event visibility.
547
+ fireEvent.click(custom)
548
+ expect(container.textContent).toContain("Email sent")
549
+ expect(toggle).toHaveAttribute("aria-pressed", "false")
550
+ expect(container.textContent).not.toContain("Score updated +3")
551
+
552
+ // The showSystemEvents arg flips to true after the built-in toggle is clicked.
553
+ expect(seenArgs.at(-1)?.showSystemEvents).toBe(false)
554
+ fireEvent.click(toggle)
555
+ expect(toggle).toHaveAttribute("aria-pressed", "true")
556
+ expect(container.textContent).toContain("Score updated +3")
557
+ expect(seenArgs.at(-1)?.showSystemEvents).toBe(true)
558
+ })
478
559
  })
@@ -161,6 +161,18 @@ export interface InboxSortOption {
161
161
  /** Controls the visual prominence of the signal brief section. */
162
162
  export type BriefStyleVariant = "default" | "prominent"
163
163
 
164
+ /**
165
+ * Render consumer-owned controls (e.g. activity-type filters) in the right
166
+ * side of the activity timeline header, before the built-in system-events
167
+ * toggle. Receives the current visible/hidden counts and system-events state
168
+ * so the consumer can label its own controls.
169
+ */
170
+ export type RenderTimelineHeaderControls = (args: {
171
+ visibleCount: number
172
+ hiddenCount: number
173
+ showSystemEvents: boolean
174
+ }) => React.ReactNode
175
+
164
176
  export interface InboxDetailSections {
165
177
  signalBrief?: boolean
166
178
  suggestedActions?: boolean
@@ -242,6 +254,11 @@ export interface InboxViewConfig {
242
254
  timelineSystemEventsConfig?: TimelineSystemEventsConfig
243
255
  /** Number of important/attention-worthy events to highlight on the collapsed timeline header. */
244
256
  attentionCount?: number
257
+ /**
258
+ * Render consumer-owned controls (e.g. activity-type filters) in the right
259
+ * side of the activity timeline header, before the built-in system-events toggle.
260
+ */
261
+ renderTimelineHeaderControls?: RenderTimelineHeaderControls
245
262
  /** Render extra content inline with the detail title. */
246
263
  renderTitleExtra?: (item: QueueItem) => React.ReactNode
247
264
  /** Render a full-width action row below the detail title row. */
@@ -56,6 +56,7 @@ import type {
56
56
  SignalScoreData,
57
57
  BriefStyleVariant,
58
58
  TimelineSystemEventsConfig,
59
+ RenderTimelineHeaderControls,
59
60
  } from "./prototype-config"
60
61
 
61
62
  // ---------------------------------------------------------------------------
@@ -195,6 +196,13 @@ export interface DetailViewProps {
195
196
  attentionCount?: number
196
197
  /** Configuration for the system-noise events toggle (score changes, etc.). */
197
198
  timelineSystemEventsConfig?: TimelineSystemEventsConfig
199
+ /**
200
+ * Render consumer-owned controls (e.g. activity-type filters) in the right
201
+ * side of the activity timeline header, before the built-in system-events
202
+ * toggle. Receives the current visible/hidden counts and system-events state
203
+ * so the consumer can label its own controls. Behavior is unchanged when omitted.
204
+ */
205
+ renderTimelineHeaderControls?: RenderTimelineHeaderControls
198
206
 
199
207
  // ── Deprecated individual props (use timelineSystemEventsConfig instead) ──
200
208
  /** @deprecated Use `timelineSystemEventsConfig.toggleLabel`. */
@@ -223,6 +231,7 @@ function TimelineSection({
223
231
  sysEvtConfig,
224
232
  lastActivityTime,
225
233
  isCasePanel = false,
234
+ renderTimelineHeaderControls,
226
235
  }: {
227
236
  timelineEvents: TimelineEvent[]
228
237
  showTimeline: boolean
@@ -233,6 +242,7 @@ function TimelineSection({
233
242
  sysEvtConfig?: TimelineSystemEventsConfig
234
243
  lastActivityTime?: string
235
244
  isCasePanel?: boolean
245
+ renderTimelineHeaderControls?: RenderTimelineHeaderControls
236
246
  }) {
237
247
  // Single-pass partition: compute visibleEvents and hiddenCount together
238
248
  const visibleEvents: TimelineEvent[] = []
@@ -302,8 +312,9 @@ function TimelineSection({
302
312
  )}
303
313
  </button>
304
314
 
305
- {/* Right: system-events toggle, event count, and collapse affordance */}
315
+ {/* Right: consumer controls, system-events toggle, event count, and collapse affordance */}
306
316
  <div className="flex shrink-0 items-center gap-4">
317
+ {renderTimelineHeaderControls?.({ visibleCount, hiddenCount, showSystemEvents })}
307
318
  {hasSystemNoise && (
308
319
  <button
309
320
  type="button"
@@ -420,6 +431,7 @@ export function DetailView({
420
431
  onRequestApproval,
421
432
  attentionCount,
422
433
  timelineSystemEventsConfig: configProp,
434
+ renderTimelineHeaderControls,
423
435
  timelineSystemEventsToggleLabel,
424
436
  timelineSystemEventsStorageKey,
425
437
  timelineSystemEventsDefaultVisible,
@@ -628,6 +640,7 @@ export function DetailView({
628
640
  sysEvtConfig={sysEvtConfig}
629
641
  lastActivityTime={lastActivityTime}
630
642
  isCasePanel={isCasePanelV2}
643
+ renderTimelineHeaderControls={renderTimelineHeaderControls}
631
644
  />
632
645
  ) : null
633
646
 
@@ -862,6 +875,7 @@ export function PrototypeInboxView({
862
875
  sortOptions,
863
876
  activeSortId,
864
877
  onSortChange,
878
+ renderTimelineHeaderControls,
865
879
  }: PrototypeInboxViewProps) {
866
880
  const [inboxViewMode, setInboxViewMode] = React.useState<"inbox" | "list" | "detail">(
867
881
  defaultViewMode === "list" ? "list" : defaultViewMode === "split" ? "inbox" : "inbox"
@@ -1108,6 +1122,7 @@ export function PrototypeInboxView({
1108
1122
  accountDetailsButtonLabel,
1109
1123
  getAccountDetailsButtonAriaLabel,
1110
1124
  onOpenSignalBucket,
1125
+ renderTimelineHeaderControls,
1111
1126
  }
1112
1127
 
1113
1128
  return (