@handled-ai/design-system 0.18.2 → 0.18.3

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 (37) hide show
  1. package/dist/charts/chart.d.ts +1 -1
  2. package/dist/components/feedback-primitives.d.ts +2 -21
  3. package/dist/components/feedback-primitives.js +6 -90
  4. package/dist/components/feedback-primitives.js.map +1 -1
  5. package/dist/components/score-why-chips.d.ts +1 -1
  6. package/dist/components/score-why-chips.js +5 -26
  7. package/dist/components/score-why-chips.js.map +1 -1
  8. package/dist/components/signal-priority-popover.d.ts +1 -1
  9. package/dist/components/signal-priority-popover.js +7 -172
  10. package/dist/components/signal-priority-popover.js.map +1 -1
  11. package/dist/components/timeline-activity.d.ts +16 -1
  12. package/dist/components/timeline-activity.js +69 -1
  13. package/dist/components/timeline-activity.js.map +1 -1
  14. package/dist/index.d.ts +3 -3
  15. package/dist/index.js.map +1 -1
  16. package/dist/prototype/index.d.ts +1 -1
  17. package/dist/prototype/prototype-accounts-view.d.ts +1 -1
  18. package/dist/prototype/prototype-admin-view.d.ts +1 -1
  19. package/dist/prototype/prototype-config.d.ts +1 -1
  20. package/dist/prototype/prototype-inbox-view.d.ts +12 -2
  21. package/dist/prototype/prototype-inbox-view.js +102 -37
  22. package/dist/prototype/prototype-inbox-view.js.map +1 -1
  23. package/dist/prototype/prototype-insights-view.d.ts +1 -1
  24. package/dist/prototype/prototype-shell.d.ts +1 -1
  25. package/dist/{signal-priority-popover-DWaAMhPI.d.ts → signal-priority-popover-DQ_VuHac.d.ts} +2 -26
  26. package/package.json +1 -3
  27. package/src/components/__tests__/timeline-activity.test.tsx +137 -0
  28. package/src/components/feedback-primitives.tsx +26 -148
  29. package/src/components/score-why-chips.tsx +2 -28
  30. package/src/components/signal-priority-popover.tsx +3 -194
  31. package/src/components/timeline-activity.tsx +112 -1
  32. package/src/index.ts +1 -1
  33. package/src/prototype/__tests__/detail-view-attention.test.tsx +2 -2
  34. package/src/prototype/__tests__/detail-view-timeline-system-events.test.tsx +322 -0
  35. package/src/prototype/prototype-config.ts +1 -11
  36. package/src/prototype/prototype-inbox-view.tsx +131 -33
  37. package/src/components/__tests__/wit-636-feedback-states.test.tsx +0 -546
@@ -0,0 +1,322 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest"
2
+ import React from "react"
3
+ import { render, fireEvent } from "@testing-library/react"
4
+ import { DetailView, type DetailViewProps } from "../prototype-inbox-view"
5
+ import type { QueueItem, SignalScoreData } from "../prototype-config"
6
+ import type { TimelineEvent } from "../../components/timeline-activity"
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Helpers
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const baseItem: QueueItem = {
13
+ id: "1",
14
+ title: "Test Signal",
15
+ details: "Some details",
16
+ statusColor: "green",
17
+ time: "2h ago",
18
+ company: "Acme Inc",
19
+ tag1: "renewal",
20
+ }
21
+
22
+ function makeSignalScore(): SignalScoreData {
23
+ return {
24
+ score: 75,
25
+ factors: [
26
+ { key: "trigger", label: "Trigger strength", score: 70, why: "Strong signal" },
27
+ ],
28
+ whyNow: "Strong signals detected.",
29
+ evidence: ["Evidence line 1"],
30
+ confidence: 80,
31
+ }
32
+ }
33
+
34
+ const normalEvents: TimelineEvent[] = [
35
+ {
36
+ id: "t1",
37
+ icon: React.createElement("span", null, "📧"),
38
+ title: "Email sent",
39
+ time: "1h ago",
40
+ },
41
+ {
42
+ id: "t2",
43
+ icon: React.createElement("span", null, "📞"),
44
+ title: "Call logged",
45
+ time: "3h ago",
46
+ },
47
+ ]
48
+
49
+ const noiseEvents: TimelineEvent[] = [
50
+ {
51
+ id: "t3",
52
+ icon: React.createElement("span", null, "📊"),
53
+ title: "Score updated +3",
54
+ time: "2h ago",
55
+ isSystemNoise: true,
56
+ },
57
+ {
58
+ id: "t4",
59
+ icon: React.createElement("span", null, "📊"),
60
+ title: "Score updated -1",
61
+ time: "4h ago",
62
+ isSystemNoise: true,
63
+ },
64
+ ]
65
+
66
+ const mixedEvents: TimelineEvent[] = [...normalEvents, ...noiseEvents]
67
+
68
+ function baseProps(overrides: Partial<DetailViewProps> = {}): DetailViewProps {
69
+ return {
70
+ item: baseItem,
71
+ sections: { signalBrief: true, suggestedActions: false, timeline: true },
72
+ getSignalScore: () => makeSignalScore(),
73
+ buildSuggestedActions: () => [],
74
+ buildSourceItems: () => [],
75
+ getTimelineEvents: () => mixedEvents,
76
+ accountContacts: [],
77
+ emailSignature: "",
78
+ iconMap: {},
79
+ timelineSystemEventsToggleLabel: "Score changes",
80
+ timelineSystemEventsStorageKey: "test-show-score-changes",
81
+ timelineSystemEventsHiddenHint: "Score changes are hidden.",
82
+ timelineSystemEventsVisibleHint: "Showing {count} score changes.",
83
+ ...overrides,
84
+ }
85
+ }
86
+
87
+ // Mock localStorage
88
+ let store: Record<string, string> = {}
89
+
90
+ const localStorageMock = {
91
+ getItem: vi.fn((key: string) => store[key] ?? null),
92
+ setItem: vi.fn((key: string, value: string) => {
93
+ store[key] = value
94
+ }),
95
+ removeItem: vi.fn((key: string) => {
96
+ delete store[key]
97
+ }),
98
+ clear: vi.fn(() => {
99
+ store = {}
100
+ }),
101
+ }
102
+
103
+ beforeEach(() => {
104
+ store = {}
105
+ localStorageMock.getItem.mockClear()
106
+ localStorageMock.setItem.mockClear()
107
+ localStorageMock.removeItem.mockClear()
108
+ localStorageMock.clear.mockClear()
109
+ // Reset getItem to default implementation
110
+ localStorageMock.getItem.mockImplementation((key: string) => store[key] ?? null)
111
+ Object.defineProperty(window, "localStorage", { value: localStorageMock, writable: true })
112
+ })
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Helper to expand the timeline
116
+ // ---------------------------------------------------------------------------
117
+
118
+ function expandTimeline(container: HTMLElement) {
119
+ const collapseBtn = container.querySelector(
120
+ '[data-testid="timeline-collapse-btn"]',
121
+ ) as HTMLElement
122
+ if (collapseBtn) fireEvent.click(collapseBtn)
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Tests
127
+ // ---------------------------------------------------------------------------
128
+
129
+ describe("DetailView timeline system-events toggle", () => {
130
+ it("hides events with isSystemNoise: true by default", () => {
131
+ const { container } = render(<DetailView {...baseProps()} />)
132
+ // Expand the timeline
133
+ expandTimeline(container)
134
+ // Should only show 2 normal events, not the 2 noise events
135
+ const eventCount = container.querySelector('[data-testid="event-count"]')
136
+ expect(eventCount?.textContent).toBe("2 events")
137
+ // Noise event titles should not appear
138
+ expect(container.textContent).not.toContain("Score updated +3")
139
+ expect(container.textContent).not.toContain("Score updated -1")
140
+ })
141
+
142
+ it("reveals system-noise events when toggle is clicked", () => {
143
+ const { container } = render(<DetailView {...baseProps()} />)
144
+ expandTimeline(container)
145
+ // Click the toggle
146
+ const toggle = container.querySelector(
147
+ '[data-testid="system-events-toggle"]',
148
+ ) as HTMLElement
149
+ expect(toggle).not.toBeNull()
150
+ fireEvent.click(toggle)
151
+ // Now all 4 events should be visible
152
+ const eventCount = container.querySelector('[data-testid="event-count"]')
153
+ expect(eventCount?.textContent).toBe("4 events")
154
+ expect(container.textContent).toContain("Score updated +3")
155
+ })
156
+
157
+ it("clicking the toggle does NOT collapse/expand the timeline", () => {
158
+ const { container } = render(<DetailView {...baseProps()} />)
159
+ expandTimeline(container)
160
+ // Timeline should be expanded — normal events visible
161
+ expect(container.textContent).toContain("Email sent")
162
+
163
+ // Click the system-events toggle
164
+ const toggle = container.querySelector(
165
+ '[data-testid="system-events-toggle"]',
166
+ ) as HTMLElement
167
+ fireEvent.click(toggle)
168
+
169
+ // Timeline should still be expanded
170
+ expect(container.textContent).toContain("Email sent")
171
+ })
172
+
173
+ it("clicking the collapse button does NOT toggle system-event visibility", () => {
174
+ const { container } = render(<DetailView {...baseProps()} />)
175
+ expandTimeline(container)
176
+
177
+ // Toggle system events on
178
+ const toggle = container.querySelector(
179
+ '[data-testid="system-events-toggle"]',
180
+ ) as HTMLElement
181
+ fireEvent.click(toggle)
182
+ expect(container.textContent).toContain("Score updated +3")
183
+
184
+ // Collapse the timeline
185
+ expandTimeline(container)
186
+ // Re-expand
187
+ expandTimeline(container)
188
+
189
+ // System events should still be visible (toggle didn't change)
190
+ expect(container.textContent).toContain("Score updated +3")
191
+ })
192
+
193
+ it("header event count reflects visible events, not total events", () => {
194
+ const { container } = render(<DetailView {...baseProps()} />)
195
+ const eventCount = container.querySelector('[data-testid="event-count"]')
196
+ expect(eventCount?.textContent).toBe("2 events")
197
+ })
198
+
199
+ it("hidden count badge shows the correct number", () => {
200
+ const { container } = render(<DetailView {...baseProps()} />)
201
+ const badge = container.querySelector('[data-testid="hidden-count-badge"]')
202
+ expect(badge).not.toBeNull()
203
+ expect(badge?.textContent).toBe("2")
204
+ })
205
+
206
+ it("calls localStorage.setItem when toggle changes", () => {
207
+ const { container } = render(<DetailView {...baseProps()} />)
208
+ expandTimeline(container)
209
+ const toggle = container.querySelector(
210
+ '[data-testid="system-events-toggle"]',
211
+ ) as HTMLElement
212
+ fireEvent.click(toggle)
213
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
214
+ "test-show-score-changes",
215
+ "true",
216
+ )
217
+ })
218
+
219
+ it("reads localStorage.getItem on mount and honors stored value", () => {
220
+ store["test-show-score-changes"] = "true"
221
+ const { container } = render(<DetailView {...baseProps()} />)
222
+ expandTimeline(container)
223
+ // With stored value "true", system events should be visible
224
+ const eventCount = container.querySelector('[data-testid="event-count"]')
225
+ expect(eventCount?.textContent).toBe("4 events")
226
+ expect(localStorageMock.getItem).toHaveBeenCalledWith(
227
+ "test-show-score-changes",
228
+ )
229
+ })
230
+
231
+ it("renders header and toggle when all events are system noise and toggle is off", () => {
232
+ const allNoiseProps = baseProps({
233
+ getTimelineEvents: () => noiseEvents,
234
+ })
235
+ const { container } = render(<DetailView {...allNoiseProps} />)
236
+ // Header should still be rendered
237
+ const header = container.querySelector('[data-testid="timeline-header"]')
238
+ expect(header).not.toBeNull()
239
+ // Toggle should be present
240
+ const toggle = container.querySelector(
241
+ '[data-testid="system-events-toggle"]',
242
+ )
243
+ expect(toggle).not.toBeNull()
244
+ // Event count should show 0 events
245
+ const eventCount = container.querySelector('[data-testid="event-count"]')
246
+ expect(eventCount?.textContent).toBe("0 events")
247
+ })
248
+
249
+ it("'Last activity' uses the first visible event's time, not a hidden system-noise event", () => {
250
+ // Arrange: first event is system noise (time "30m ago"),
251
+ // second event is normal (time "1h ago")
252
+ const orderedEvents: TimelineEvent[] = [
253
+ {
254
+ id: "noise-first",
255
+ icon: React.createElement("span", null, "📊"),
256
+ title: "Score change",
257
+ time: "30m ago",
258
+ isSystemNoise: true,
259
+ },
260
+ {
261
+ id: "normal-second",
262
+ icon: React.createElement("span", null, "📧"),
263
+ title: "Email sent",
264
+ time: "1h ago",
265
+ },
266
+ ]
267
+ const props = baseProps({ getTimelineEvents: () => orderedEvents })
268
+ const { container } = render(<DetailView {...props} />)
269
+ const hint = container.querySelector('[data-testid="last-activity-hint"]')
270
+ expect(hint).not.toBeNull()
271
+ // Should show "1h ago" (the first visible event), not "30m ago" (the hidden noise event)
272
+ expect(hint?.textContent).toContain("1h ago")
273
+ expect(hint?.textContent).not.toContain("30m ago")
274
+ })
275
+
276
+ it("uses singular grammar for 1 event and plural for multiple", () => {
277
+ const singleEvent: TimelineEvent[] = [
278
+ {
279
+ id: "s1",
280
+ icon: React.createElement("span", null, "📧"),
281
+ title: "Email sent",
282
+ time: "1h ago",
283
+ },
284
+ ]
285
+ const props = baseProps({ getTimelineEvents: () => singleEvent })
286
+ const { container } = render(<DetailView {...props} />)
287
+ const eventCount = container.querySelector('[data-testid="event-count"]')
288
+ expect(eventCount?.textContent).toBe("1 event")
289
+ })
290
+
291
+ it("does not render toggle when there are no system-noise events", () => {
292
+ const props = baseProps({
293
+ getTimelineEvents: () => normalEvents,
294
+ })
295
+ const { container } = render(<DetailView {...props} />)
296
+ const toggle = container.querySelector(
297
+ '[data-testid="system-events-toggle"]',
298
+ )
299
+ expect(toggle).toBeNull()
300
+ })
301
+
302
+ it("shows footer hint when timeline is expanded and system events are hidden", () => {
303
+ const { container } = render(<DetailView {...baseProps()} />)
304
+ expandTimeline(container)
305
+ const hint = container.querySelector('[data-testid="timeline-footer-hint"]')
306
+ expect(hint).not.toBeNull()
307
+ expect(hint?.textContent).toBe("Score changes are hidden.")
308
+ })
309
+
310
+ it("shows visible footer hint with count when system events are shown", () => {
311
+ const { container } = render(<DetailView {...baseProps()} />)
312
+ expandTimeline(container)
313
+ // Toggle on
314
+ const toggle = container.querySelector(
315
+ '[data-testid="system-events-toggle"]',
316
+ ) as HTMLElement
317
+ fireEvent.click(toggle)
318
+ const hint = container.querySelector('[data-testid="timeline-footer-hint"]')
319
+ expect(hint).not.toBeNull()
320
+ expect(hint?.textContent).toBe("Showing 2 score changes.")
321
+ })
322
+ })
@@ -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, PersistedFeedbackData } from "../components/feedback-primitives"
19
+ import type { FeedbackChipTree, FeedbackSubmitData } from "../components/feedback-primitives"
20
20
 
21
21
  // ---------------------------------------------------------------------------
22
22
  // Shared
@@ -57,10 +57,6 @@ 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
64
60
  }
65
61
 
66
62
  export interface SignalScoreExplanationBucket {
@@ -101,12 +97,6 @@ export interface SignalScoreData {
101
97
  /** @deprecated The compact score UX no longer renders score-level thumbs by default. */
102
98
  initialScoreFeedback?: { type: "up" | "down"; pills: string[]; detail: string } | null
103
99
  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
110
100
  /** Priority factors for the popover breakdown. */
111
101
  priorityFactors?: PriorityFactor[]
112
102
  /** Negative feedback chip tree for the priority popover. */
@@ -150,6 +150,16 @@ export interface DetailViewProps {
150
150
  onRequestApproval?: () => Promise<void>
151
151
  /** Number of important/attention-worthy events to highlight on the collapsed timeline header. */
152
152
  attentionCount?: number
153
+ /** Label for the system-events toggle button (e.g. "Score changes"). */
154
+ timelineSystemEventsToggleLabel?: string
155
+ /** localStorage key for persisting the system-events toggle state. */
156
+ timelineSystemEventsStorageKey?: string
157
+ /** Whether system-noise events are visible by default. @default false */
158
+ timelineSystemEventsDefaultVisible?: boolean
159
+ /** Hint text shown below the timeline when system events are hidden. */
160
+ timelineSystemEventsHiddenHint?: string
161
+ /** Hint text shown below the timeline when system events are visible. Uses {count} as placeholder. */
162
+ timelineSystemEventsVisibleHint?: string
153
163
  }
154
164
 
155
165
  export function DetailView({
@@ -184,10 +194,47 @@ export function DetailView({
184
194
  opportunityPreview,
185
195
  onRequestApproval,
186
196
  attentionCount,
197
+ timelineSystemEventsToggleLabel,
198
+ timelineSystemEventsStorageKey,
199
+ timelineSystemEventsDefaultVisible = false,
200
+ timelineSystemEventsHiddenHint,
201
+ timelineSystemEventsVisibleHint,
187
202
  }: DetailViewProps) {
188
203
  const [showTimeline, setShowTimeline] = React.useState(false)
189
204
  const [extraActions, setExtraActions] = React.useState<SuggestedAction[]>([])
190
205
 
206
+ // ---- System-noise toggle state ----
207
+ const [showSystemEvents, setShowSystemEvents] = React.useState(timelineSystemEventsDefaultVisible)
208
+ const initialReadDoneRef = React.useRef(false)
209
+
210
+ // Read persisted value from localStorage on mount
211
+ React.useEffect(() => {
212
+ if (!timelineSystemEventsStorageKey) {
213
+ initialReadDoneRef.current = true
214
+ return
215
+ }
216
+ try {
217
+ const stored = localStorage.getItem(timelineSystemEventsStorageKey)
218
+ if (stored !== null) {
219
+ setShowSystemEvents(stored === "true")
220
+ }
221
+ } catch {
222
+ // localStorage unavailable — ignore
223
+ }
224
+ initialReadDoneRef.current = true
225
+ }, [timelineSystemEventsStorageKey])
226
+
227
+ // Write to localStorage when the toggle changes (skip initial if matching default)
228
+ React.useEffect(() => {
229
+ if (!timelineSystemEventsStorageKey) return
230
+ if (!initialReadDoneRef.current) return
231
+ try {
232
+ localStorage.setItem(timelineSystemEventsStorageKey, String(showSystemEvents))
233
+ } catch {
234
+ // localStorage unavailable — ignore
235
+ }
236
+ }, [showSystemEvents, timelineSystemEventsStorageKey])
237
+
191
238
  React.useEffect(() => {
192
239
  setShowTimeline(false)
193
240
  setExtraActions([])
@@ -272,9 +319,6 @@ export function DetailView({
272
319
  metaText={undefined}
273
320
  feedbackChips={signalData.priorityFeedbackChips}
274
321
  onFeedbackSubmit={signalData.onPriorityFeedback}
275
- initialFactorFeedback={signalData.initialFactorPopoverFeedback}
276
- onFactorFeedback={signalData.onFactorFeedback}
277
- initialPriorityFeedback={signalData.initialPriorityFeedback}
278
322
  />
279
323
  {signalData.timeChipLabel && (
280
324
  <Badge variant="outline" title={signalData.timeChipDetail ?? undefined}>
@@ -354,38 +398,92 @@ export function DetailView({
354
398
  {renderAfterScore?.(item)}
355
399
 
356
400
  {/* Activity Timeline */}
357
- {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>
401
+ {sections.timeline && timelineEvents.length > 0 && (() => {
402
+ const visibleEvents = timelineEvents.filter(
403
+ (e) => !e.isSystemNoise || showSystemEvents,
404
+ )
405
+ const hiddenCount = timelineEvents.filter((e) => e.isSystemNoise).length
406
+ const hasSystemNoise = hiddenCount > 0
407
+ const showToggle =
408
+ hasSystemNoise &&
409
+ !!(timelineSystemEventsToggleLabel || timelineSystemEventsStorageKey)
410
+ const firstVisibleTime =
411
+ lastActivityTime ?? (visibleEvents.length > 0 ? visibleEvents[0].time : "")
412
+ const visibleCount = visibleEvents.length
413
+ const eventCountLabel = `${visibleCount} ${visibleCount === 1 ? "event" : "events"}`
414
+
415
+ return (
416
+ <div className="mb-8">
417
+ {/* Header outer non-interactive container */}
418
+ <div
419
+ 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"
420
+ data-testid="timeline-header"
421
+ >
422
+ {/* Left: collapse/expand button */}
423
+ <button
424
+ type="button"
425
+ onClick={() => setShowTimeline((prev) => !prev)}
426
+ className="flex items-center gap-2 cursor-pointer bg-transparent border-0 p-0"
427
+ data-testid="timeline-collapse-btn"
428
+ >
429
+ <h3 className="text-xs font-bold text-muted-foreground uppercase tracking-wider group-hover/timeline:text-foreground transition-colors">Activity timeline</h3>
430
+ {!showTimeline && attentionCount != null && attentionCount > 0 && (
431
+ <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">
432
+ {attentionCount} new
433
+ </span>
434
+ )}
435
+ {!showTimeline && firstVisibleTime && (
436
+ <span className="text-[11px] text-muted-foreground/60" data-testid="last-activity-hint">
437
+ &middot; Last activity {firstVisibleTime}
438
+ </span>
439
+ )}
440
+ <div className="flex items-center gap-1.5">
441
+ <span className="text-[11px] font-medium text-muted-foreground" data-testid="event-count">{eventCountLabel}</span>
442
+ <ChevronDown className={`h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${showTimeline ? "rotate-180" : ""}`} />
443
+ </div>
444
+ </button>
445
+
446
+ {/* Right: system-events toggle */}
447
+ {showToggle && (
448
+ <button
449
+ type="button"
450
+ onClick={() => setShowSystemEvents((prev) => !prev)}
451
+ 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"
452
+ aria-pressed={showSystemEvents}
453
+ data-testid="system-events-toggle"
454
+ >
455
+ {timelineSystemEventsToggleLabel ?? "System events"}
456
+ <span
457
+ className="inline-flex items-center justify-center rounded-full bg-muted px-1.5 text-[10px] font-semibold min-w-[18px] tabular-nums"
458
+ data-testid="hidden-count-badge"
459
+ >
460
+ {hiddenCount}
461
+ </span>
462
+ </button>
375
463
  )}
376
464
  </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>
388
- )}
465
+
466
+ {/* Timeline body */}
467
+ {showTimeline && visibleEvents.length > 0 && (
468
+ <div className="mt-3">
469
+ <TimelineActivity events={visibleEvents} />
470
+ </div>
471
+ )}
472
+
473
+ {/* Footer hint */}
474
+ {showTimeline && !showSystemEvents && timelineSystemEventsHiddenHint && hasSystemNoise && (
475
+ <p className="mt-2 text-[11px] text-muted-foreground/60 border-t border-dashed border-border pt-2" data-testid="timeline-footer-hint">
476
+ {timelineSystemEventsHiddenHint}
477
+ </p>
478
+ )}
479
+ {showTimeline && showSystemEvents && timelineSystemEventsVisibleHint && hasSystemNoise && (
480
+ <p className="mt-2 text-[11px] text-muted-foreground/60 border-t border-dashed border-border pt-2" data-testid="timeline-footer-hint">
481
+ {timelineSystemEventsVisibleHint.replace("{count}", String(hiddenCount))}
482
+ </p>
483
+ )}
484
+ </div>
485
+ )
486
+ })()}
389
487
  </div>
390
488
 
391
489
  {/* Suggested Actions */}