@handled-ai/design-system 0.20.10 → 0.20.11

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.
@@ -20,19 +20,16 @@ describe("CommentComposer", () => {
20
20
  it("exposes data-open only when expanded (collapsed vs open structure)", () => {
21
21
  const { container } = render(<CommentComposer onPost={() => {}} />);
22
22
  const root = container.querySelector('[data-slot="comment-composer"]')!;
23
- // Collapsed: no data-open attribute, single-row textarea in a rounded input shell, no footer.
23
+ // Collapsed: no data-open attribute, compact single-row textarea, no footer.
24
24
  expect(root.getAttribute("data-open")).toBeNull();
25
- expect(root.className).toContain("gap-4");
26
25
  const input = screen.getByRole("textbox") as HTMLTextAreaElement;
27
26
  expect(Number(input.rows)).toBe(1);
28
- expect(input.className).toContain("min-h-14");
29
27
  expect(screen.queryByText("Comment")).toBeNull();
30
28
 
31
29
  // Open: data-open=true, taller textarea, footer with Cancel + Comment.
32
30
  fireEvent.focus(input);
33
31
  expect(root.getAttribute("data-open")).toBe("true");
34
- expect(Number(input.rows)).toBe(4);
35
- expect(input.className).toContain("min-h-32");
32
+ expect(Number(input.rows)).toBe(3);
36
33
  expect(screen.getByText("Cancel")).toBeDefined();
37
34
  expect(screen.getByText("Comment")).toBeDefined();
38
35
  });
@@ -41,7 +38,7 @@ describe("CommentComposer", () => {
41
38
  render(<CommentComposer onPost={() => {}} />);
42
39
  const input = screen.getByRole("textbox") as HTMLTextAreaElement;
43
40
  fireEvent.change(input, { target: { value: "draft" } });
44
- expect(Number(input.rows)).toBe(4);
41
+ expect(Number(input.rows)).toBe(3);
45
42
  fireEvent.click(screen.getByText("Cancel").closest("button")!);
46
43
  expect(input.value).toBe("");
47
44
  expect(Number(input.rows)).toBe(1);
@@ -54,7 +51,6 @@ describe("CommentComposer", () => {
54
51
  fireEvent.focus(input);
55
52
  const btn = screen.getByText("Comment").closest("button")!;
56
53
  expect(btn.disabled).toBe(true);
57
- expect(btn.className).toContain("bg-foreground");
58
54
  fireEvent.change(input, { target: { value: " hi " } });
59
55
  expect(btn.disabled).toBe(false);
60
56
  });
@@ -80,4 +80,36 @@ describe("EmailBody", () => {
80
80
  expect(container.textContent).toContain("Line one & two\nLine 'three'")
81
81
  expectNoVisibleEscapeArtifacts(container.textContent ?? "")
82
82
  })
83
+
84
+ it("linkifies URLs and email addresses in plain text bodies after splitting details", () => {
85
+ const { container } = render(
86
+ <EmailBody
87
+ text={[
88
+ "Hi Dana,",
89
+ "",
90
+ "Please review https://example.com/report?case=123, then email ops@example.com.",
91
+ "",
92
+ "-- ",
93
+ "Jane Doe",
94
+ "https://example.com/signature",
95
+ ].join("\n")}
96
+ collapseDetails
97
+ />,
98
+ )
99
+
100
+ const body = container.querySelector('[data-slot="email-body-content"]')
101
+ const details = container.querySelector('[data-slot="email-body-details"]')
102
+
103
+ expect(body?.textContent).toContain("Please review https://example.com/report?case=123, then email ops@example.com.")
104
+ expect(body?.querySelector('a[href="https://example.com/report?case=123"]')?.textContent).toBe("https://example.com/report?case=123")
105
+ expect(body?.querySelector('a[href="mailto:ops@example.com"]')?.textContent).toBe("ops@example.com")
106
+ expect(body?.textContent).toContain("https://example.com/report?case=123,")
107
+ expect(details).toBeNull()
108
+
109
+ fireEvent.click(screen.getByRole("button", { name: "•••" }))
110
+
111
+ const expandedDetails = container.querySelector('[data-slot="email-body-details"]')
112
+ expect(expandedDetails?.textContent).toContain("Jane Doe")
113
+ expect(expandedDetails?.querySelector('a[href="https://example.com/signature"]')?.textContent).toBe("https://example.com/signature")
114
+ })
83
115
  })
@@ -340,6 +340,42 @@ describe("TimelineActivity", () => {
340
340
  )
341
341
  })
342
342
 
343
+ it.each(["default", "case-panel"] as const)(
344
+ "renders email source actionLabel, content, and Show less for the %s variant",
345
+ (variant) => {
346
+ const threadUrl = "https://mail.google.com/mail/u/0/#inbox/thread-1"
347
+ const event = minimal({
348
+ isInteractive: true,
349
+ defaultExpanded: true,
350
+ content: "Moved to waiting",
351
+ email: {
352
+ from: "Priya Raman",
353
+ to: "Dana Okafor",
354
+ subject: "Re: hi",
355
+ body: "plain fallback",
356
+ },
357
+ source: { label: "Gmail", actionLabel: "Open thread", url: threadUrl },
358
+ })
359
+
360
+ const { container } = render(<TimelineActivity events={[event]} variant={variant} />)
361
+
362
+ const sourceAction = screen.getByRole("link", { name: /Open thread/i })
363
+ expect(sourceAction).toHaveAttribute("href", threadUrl)
364
+ expect(screen.queryByRole("link", { name: /Open in Gmail/i })).toBeNull()
365
+ expect(screen.getByText("Moved to waiting")).toBeDefined()
366
+
367
+ const footer = variant === "case-panel"
368
+ ? container.querySelector('[data-slot="timeline-card-footer"]')
369
+ : sourceAction.closest("div")
370
+ const footerText = footer?.textContent ?? ""
371
+ const sourceActionIndex = footerText.indexOf("Open thread")
372
+ const showLessIndex = footerText.indexOf("Show less")
373
+
374
+ expect(sourceActionIndex).toBeGreaterThanOrEqual(0)
375
+ expect(showLessIndex).toBeGreaterThan(sourceActionIndex)
376
+ },
377
+ )
378
+
343
379
  it("uses shared helpers for decoded timeline sender, timestamp, snippets, and collapsed details", () => {
344
380
  const event = minimal({
345
381
  isInteractive: true,
@@ -55,34 +55,29 @@ function CommentComposer({
55
55
  data-slot="comment-composer"
56
56
  data-open={open ? "true" : undefined}
57
57
  className={cn(
58
- "flex items-start gap-4 rounded-xl transition-colors",
58
+ "border-border bg-background flex items-start gap-2 rounded-lg border px-2 py-1.5 transition-colors",
59
+ open && "ring-ring/30 ring-2",
59
60
  className
60
61
  )}
61
62
  >
62
- <Avatar size="sm" className="mt-1">
63
+ <Avatar size="sm" className="mt-px">
63
64
  {author?.avatarUrl ? <AvatarImage src={author.avatarUrl} alt={author.name ?? "You"} /> : null}
64
- <AvatarFallback className="bg-slate-700 text-[10px] font-semibold uppercase text-white dark:bg-slate-200 dark:text-slate-900">
65
+ <AvatarFallback className="bg-muted text-muted-foreground text-[10px] font-medium uppercase">
65
66
  {getInitials({ name: author?.name, email: author?.email })}
66
67
  </AvatarFallback>
67
68
  </Avatar>
68
69
 
69
- <div
70
- data-slot="comment-composer-shell"
71
- className={cn(
72
- "min-w-0 flex-1 rounded-xl border border-border bg-background transition-[box-shadow,border-color]",
73
- open ? "overflow-hidden shadow-sm" : "shadow-none"
74
- )}
75
- >
70
+ <div className="min-w-0 flex-1">
76
71
  <Textarea
77
72
  data-slot="comment-composer-input"
78
73
  value={text}
79
74
  onChange={(e) => setText(e.target.value)}
80
75
  onFocus={() => setFocused(true)}
81
76
  placeholder={placeholder}
82
- rows={open ? 4 : 1}
77
+ rows={open ? 3 : 1}
83
78
  className={cn(
84
- "resize-none rounded-none border-0 bg-transparent px-5 py-4 text-[15px] leading-6 shadow-none outline-none placeholder:text-muted-foreground/60 focus-visible:ring-0 focus-visible:ring-offset-0",
85
- open ? "min-h-32" : "min-h-14"
79
+ "resize-none border-0 bg-transparent px-1 py-0.5 text-sm leading-snug shadow-none focus-visible:ring-0",
80
+ !open && "min-h-0"
86
81
  )}
87
82
  onKeyDown={(e) => {
88
83
  if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
@@ -93,16 +88,15 @@ function CommentComposer({
93
88
  />
94
89
 
95
90
  {open ? (
96
- <div className="flex items-center justify-between gap-3 border-t border-border bg-muted/10 px-5 py-4">
97
- <span className="inline-flex items-center gap-2 text-sm text-muted-foreground">
98
- <Lock size={16} strokeWidth={1.75} /> {hint}
91
+ <div className="mt-0.5 flex items-center justify-between gap-2">
92
+ <span className="text-muted-foreground inline-flex items-center gap-1 text-[11px]">
93
+ <Lock size={12} /> {hint}
99
94
  </span>
100
- <span className="flex items-center gap-3">
95
+ <span className="flex items-center gap-2">
101
96
  <Button
102
97
  type="button"
103
98
  variant="ghost"
104
99
  size="sm"
105
- className="px-2 text-sm font-medium text-muted-foreground hover:bg-transparent hover:text-foreground"
106
100
  onClick={() => {
107
101
  setText("")
108
102
  setFocused(false)
@@ -110,15 +104,9 @@ function CommentComposer({
110
104
  >
111
105
  Cancel
112
106
  </Button>
113
- <Button
114
- type="button"
115
- size="sm"
116
- disabled={!canPost}
117
- onClick={post}
118
- className="rounded-lg bg-foreground px-4 text-sm font-semibold text-background shadow-none hover:bg-foreground/90"
119
- >
107
+ <Button type="button" size="sm" disabled={!canPost} onClick={post}>
120
108
  Comment
121
- <kbd className="ml-1 rounded px-1 text-[10px] text-background/70">⌘↵</kbd>
109
+ <kbd className="bg-primary-foreground/15 ml-1 rounded px-1 text-[10px]">⌘↵</kbd>
122
110
  </Button>
123
111
  </span>
124
112
  </div>
@@ -33,10 +33,55 @@ const PROSE = cn(
33
33
  "[&_sup]:align-super [&_sup]:text-[0.75em] [&_sub]:align-sub [&_sub]:text-[0.75em]",
34
34
  )
35
35
 
36
+ const PLAIN_TEXT_LINK_RE = /https?:\/\/[^\s<>"']+|www\.[^\s<>"']+|[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi
37
+ const TRAILING_LINK_PUNCTUATION_RE = /[),.;:!?]+$/
38
+
39
+ function plainTextHref(value: string): string | null {
40
+ if (/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(value)) {
41
+ return `mailto:${value}`
42
+ }
43
+ const href = value.toLowerCase().startsWith("www.") ? `https://${value}` : value
44
+ try {
45
+ const url = new URL(href)
46
+ return url.protocol === "http:" || url.protocol === "https:" ? href : null
47
+ } catch {
48
+ return null
49
+ }
50
+ }
51
+
52
+ function linkifyPlainText(text: string): React.ReactNode[] {
53
+ const nodes: React.ReactNode[] = []
54
+ let cursor = 0
55
+
56
+ for (const match of text.matchAll(PLAIN_TEXT_LINK_RE)) {
57
+ const value = match[0]
58
+ const index = match.index ?? 0
59
+ if (index > cursor) nodes.push(text.slice(cursor, index))
60
+
61
+ const trailing = value.match(TRAILING_LINK_PUNCTUATION_RE)?.[0] ?? ""
62
+ const linkText = trailing ? value.slice(0, -trailing.length) : value
63
+ const href = plainTextHref(linkText)
64
+ if (href) {
65
+ nodes.push(
66
+ <a key={`${index}-${linkText}`} href={href} target="_blank" rel="noreferrer noopener">
67
+ {linkText}
68
+ </a>,
69
+ )
70
+ } else {
71
+ nodes.push(linkText)
72
+ }
73
+ if (trailing) nodes.push(trailing)
74
+ cursor = index + value.length
75
+ }
76
+
77
+ if (cursor < text.length) nodes.push(text.slice(cursor))
78
+ return nodes
79
+ }
80
+
36
81
  function PlainTextBlock({ text, className, slot }: { text: string; className?: string; slot: string }) {
37
82
  return (
38
83
  <div data-slot={slot} className={cn(PROSE, "whitespace-pre-line", className)}>
39
- {decodeEmailDisplayText(text)}
84
+ {linkifyPlainText(decodeEmailDisplayText(text))}
40
85
  </div>
41
86
  )
42
87
  }
@@ -58,6 +58,7 @@ export interface TimelineEvent {
58
58
  content?: React.ReactNode
59
59
  source?: {
60
60
  label: string
61
+ actionLabel?: string
61
62
  url: string
62
63
  }
63
64
  defaultExpanded?: boolean
@@ -470,6 +471,8 @@ function SourceAction({
470
471
  onSourceClick?: () => void
471
472
  className: string
472
473
  }) {
474
+ const actionLabel = source.actionLabel ?? `Open in ${source.label}`
475
+
473
476
  if (onSourceClick) {
474
477
  return (
475
478
  <button
@@ -477,7 +480,7 @@ function SourceAction({
477
480
  onClick={(e) => { e.stopPropagation(); onSourceClick(); }}
478
481
  className={className}
479
482
  >
480
- Open in {source.label}
483
+ {actionLabel}
481
484
  <ExternalLink className="h-3 w-3" />
482
485
  </button>
483
486
  )
@@ -490,7 +493,7 @@ function SourceAction({
490
493
  rel="noreferrer noopener"
491
494
  className={className}
492
495
  >
493
- Open in {source.label}
496
+ {actionLabel}
494
497
  <ExternalLink className="h-3 w-3" />
495
498
  </a>
496
499
  )
@@ -535,13 +538,28 @@ function EmailCard({
535
538
 
536
539
  <TimelineEmailBody email={event.email} />
537
540
 
538
- <ShowLessButton
539
- onClick={(e) => {
540
- e.stopPropagation()
541
- setExpanded(false)
542
- }}
543
- className="mt-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"
544
- />
541
+ {event.content ? (
542
+ <div className="rounded-md bg-muted/30 px-2.5 py-2 text-xs text-muted-foreground">
543
+ {event.content}
544
+ </div>
545
+ ) : null}
546
+
547
+ <div className="mt-2 flex items-center gap-3">
548
+ {event.source ? (
549
+ <SourceAction
550
+ source={event.source}
551
+ onSourceClick={event.onSourceClick}
552
+ className="mr-auto inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"
553
+ />
554
+ ) : null}
555
+ <ShowLessButton
556
+ onClick={(e) => {
557
+ e.stopPropagation()
558
+ setExpanded(false)
559
+ }}
560
+ className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"
561
+ />
562
+ </div>
545
563
  </div>
546
564
  ) : (
547
565
  <CollapsedEmailPreview
@@ -570,9 +588,24 @@ function EmailCard({
570
588
 
571
589
  <div className={classes.cardBody} data-slot="timeline-card-body">
572
590
  <TimelineEmailBody email={event.email} />
591
+ {event.content ? (
592
+ <div className="mt-3 rounded-md bg-muted/30 px-2.5 py-2 text-xs text-muted-foreground">
593
+ {event.content}
594
+ </div>
595
+ ) : null}
573
596
  </div>
574
597
 
575
- <div className={cn(classes.cardFooter, classes.actionLinkRow)} data-slot="timeline-card-footer">
598
+ <div
599
+ className={cn(classes.cardFooter, classes.actionLinkRow, event.source ? "justify-between" : "justify-end")}
600
+ data-slot="timeline-card-footer"
601
+ >
602
+ {event.source ? (
603
+ <SourceAction
604
+ source={event.source}
605
+ onSourceClick={event.onSourceClick}
606
+ className={classes.actionLink}
607
+ />
608
+ ) : null}
576
609
  <ShowLessButton
577
610
  type="button"
578
611
  onClick={(e) => {
@@ -101,7 +101,7 @@ describe("DetailView case-panel-v2 section layout", () => {
101
101
  screen.getByText("Cash movement"),
102
102
  screen.getByText("Approve action"),
103
103
  screen.getByText("After-score marker"),
104
- screen.getByText(/activity timeline/i),
104
+ screen.getByText("Activity timeline"),
105
105
  screen.getByText("Legacy detail extra marker"),
106
106
  )
107
107
  })
@@ -125,7 +125,7 @@ describe("DetailView case-panel-v2 section layout", () => {
125
125
  screen.getByText("Opportunity marker"),
126
126
  screen.getByText("Primary action marker"),
127
127
  screen.getByText("Comment area marker"),
128
- screen.getByText(/activity timeline/i),
128
+ screen.getByText("Activity timeline"),
129
129
  )
130
130
  })
131
131
 
@@ -148,7 +148,7 @@ describe("DetailView case-panel-v2 section layout", () => {
148
148
 
149
149
  expectInDocumentOrder(
150
150
  screen.getByText("Comment composer marker"),
151
- screen.getByText(/activity timeline/i),
151
+ screen.getByText("Activity timeline"),
152
152
  )
153
153
  })
154
154
 
@@ -209,22 +209,20 @@ describe("DetailView timeline system-events toggle", () => {
209
209
  const badge = container.querySelector('[data-testid="hidden-count-badge"]')
210
210
  expect(badge).not.toBeNull()
211
211
  expect(badge?.textContent).toBe("2")
212
- expect(badge).toHaveClass("min-w-[22px]")
212
+ expect(badge).toHaveClass("min-w-[18px]")
213
213
  })
214
214
 
215
- it("calls localStorage.setItem when toggle changes and shows the active pill style", () => {
215
+ it("calls localStorage.setItem when toggle changes and shows a stronger pressed style", () => {
216
216
  const { container } = render(<DetailView {...baseProps()} />)
217
217
  expandTimeline(container)
218
218
  const toggle = container.querySelector(
219
219
  '[data-testid="system-events-toggle"]',
220
220
  ) as HTMLElement
221
221
  expect(toggle).toHaveAttribute("aria-pressed", "false")
222
- expect(toggle).toHaveAttribute("title", "Score changes are hidden.")
223
222
  fireEvent.click(toggle)
224
223
  expect(toggle).toHaveAttribute("aria-pressed", "true")
225
- expect(toggle).toHaveClass("border-foreground")
226
- expect(toggle).toHaveClass("bg-foreground")
227
- expect(toggle).toHaveAttribute("title", "Showing 2 score changes.")
224
+ expect(toggle.className).toContain("border-primary/40")
225
+ expect(toggle.className).toContain("bg-primary/10")
228
226
  expect(localStorageMock.setItem).toHaveBeenCalledWith(
229
227
  "test-show-score-changes",
230
228
  "true",
@@ -314,19 +312,18 @@ describe("DetailView timeline system-events toggle", () => {
314
312
  expect(toggle).toBeNull()
315
313
  })
316
314
 
317
- it("does not render a footer hint and uses the hidden hint as toggle help", () => {
315
+ it("shows footer hint below the case-panel timeline when timeline is expanded and system events are hidden", () => {
318
316
  const { container } = render(<DetailView {...baseProps()} />)
319
317
  expandTimeline(container)
320
318
  const timeline = container.querySelector('[data-variant="case-panel"]')
321
319
  const hint = container.querySelector('[data-testid="timeline-footer-hint"]')
322
- const toggle = container.querySelector('[data-testid="system-events-toggle"]')
323
320
  expect(timeline).not.toBeNull()
324
- expect(hint).toBeNull()
325
- expect(toggle).toHaveAttribute("title", "Score changes are hidden.")
326
- expect(toggle).toHaveAttribute("aria-label", "Score changes are hidden.")
321
+ expect(hint).not.toBeNull()
322
+ expect(hint?.textContent).toBe("Score changes are hidden.")
323
+ expect(timeline?.compareDocumentPosition(hint as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
327
324
  })
328
325
 
329
- it("uses visible footer hint text as toggle help with count when system events are shown", () => {
326
+ it("shows visible footer hint with count when system events are shown", () => {
330
327
  const { container } = render(<DetailView {...baseProps()} />)
331
328
  expandTimeline(container)
332
329
  // Toggle on
@@ -335,8 +332,8 @@ describe("DetailView timeline system-events toggle", () => {
335
332
  ) as HTMLElement
336
333
  fireEvent.click(toggle)
337
334
  const hint = container.querySelector('[data-testid="timeline-footer-hint"]')
338
- expect(hint).toBeNull()
339
- expect(toggle).toHaveAttribute("title", "Showing 2 score changes.")
335
+ expect(hint).not.toBeNull()
336
+ expect(hint?.textContent).toBe("Showing 2 score changes.")
340
337
  })
341
338
 
342
339
  // --- Toggle always renders when system-noise events exist (review fix #1) ---
@@ -417,10 +414,10 @@ describe("DetailView timeline system-events toggle", () => {
417
414
  const toggle = container.querySelector('[data-testid="system-events-toggle"]')
418
415
  expect(toggle).not.toBeNull()
419
416
  expect(toggle?.textContent).toContain("Legacy label")
420
- // Deprecated hint props are accepted and exposed as toggle help.
417
+ // Footer hint should work too
421
418
  expandTimeline(container)
422
419
  const hint = container.querySelector('[data-testid="timeline-footer-hint"]')
423
- expect(hint).toBeNull()
424
- expect(toggle).toHaveAttribute("title", "Legacy hidden hint.")
420
+ expect(hint).not.toBeNull()
421
+ expect(hint?.textContent).toBe("Legacy hidden hint.")
425
422
  })
426
423
  })
@@ -222,7 +222,6 @@ function TimelineSection({
222
222
  attentionCount,
223
223
  sysEvtConfig,
224
224
  lastActivityTime,
225
- isCasePanel = false,
226
225
  }: {
227
226
  timelineEvents: TimelineEvent[]
228
227
  showTimeline: boolean
@@ -232,7 +231,6 @@ function TimelineSection({
232
231
  attentionCount?: number
233
232
  sysEvtConfig?: TimelineSystemEventsConfig
234
233
  lastActivityTime?: string
235
- isCasePanel?: boolean
236
234
  }) {
237
235
  // Single-pass partition: compute visibleEvents and hiddenCount together
238
236
  const visibleEvents: TimelineEvent[] = []
@@ -247,9 +245,6 @@ function TimelineSection({
247
245
  // config was provided — so consumers that emit `isSystemNoise: true` always
248
246
  // give users a way to reveal those events.
249
247
  const toggleLabel = sysEvtConfig?.toggleLabel ?? "System events"
250
- const toggleHelp = showSystemEvents
251
- ? sysEvtConfig?.visibleHint?.replace("{count}", String(hiddenCount)) ?? "Hide system events"
252
- : sysEvtConfig?.hiddenHint ?? "Show system events"
253
248
 
254
249
  // Derive "Last activity" from the first *visible* event so the collapsed
255
250
  // header never points at a hidden score-update. The caller-supplied
@@ -267,106 +262,84 @@ function TimelineSection({
267
262
  const eventCountLabel = `${visibleCount} ${visibleCount === 1 ? "event" : "events"}`
268
263
 
269
264
  return (
270
- <div
271
- className={cn(
272
- isCasePanel ? "mt-8 border-t border-border pt-8 pb-8" : "mb-8"
273
- )}
274
- >
265
+ <div className="mb-8">
275
266
  {/* Header — outer non-interactive container */}
276
267
  <div
277
- className={cn(
278
- "flex w-full items-center justify-between",
279
- isCasePanel
280
- ? "gap-4 border-b border-border pb-5"
281
- : "group/timeline gap-2 rounded-md py-2 transition-colors hover:bg-muted/40 -mx-2 px-2"
282
- )}
268
+ 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"
283
269
  data-testid="timeline-header"
284
270
  >
285
271
  {/* Left: collapse/expand button */}
286
272
  <button
287
273
  type="button"
288
274
  onClick={() => setShowTimeline((prev) => !prev)}
289
- className="flex min-w-0 cursor-pointer items-center gap-2 border-0 bg-transparent p-0 text-left"
275
+ className="flex items-center gap-2 cursor-pointer bg-transparent border-0 p-0"
290
276
  data-testid="timeline-collapse-btn"
291
277
  >
292
- <h3 className="text-xs font-bold uppercase tracking-[0.16em] text-muted-foreground transition-colors group-hover/timeline:text-foreground">ACTIVITY TIMELINE</h3>
278
+ <h3 className="text-xs font-bold text-muted-foreground uppercase tracking-wider group-hover/timeline:text-foreground transition-colors">Activity timeline</h3>
293
279
  {!showTimeline && attentionCount != null && attentionCount > 0 && (
294
280
  <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">
295
281
  {attentionCount} new
296
282
  </span>
297
283
  )}
298
- {!isCasePanel && !showTimeline && firstVisibleTime && (
284
+ {!showTimeline && firstVisibleTime && (
299
285
  <span className="text-[11px] text-muted-foreground/60" data-testid="last-activity-hint">
300
286
  &middot; Last activity {firstVisibleTime}
301
287
  </span>
302
288
  )}
289
+ <div className="flex items-center gap-1.5">
290
+ <span className="text-[11px] font-medium text-muted-foreground" data-testid="event-count">{eventCountLabel}</span>
291
+ <ChevronDown className={`h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${showTimeline ? "rotate-180" : ""}`} />
292
+ </div>
303
293
  </button>
304
294
 
305
- {/* Right: system-events toggle, event count, and collapse affordance */}
306
- <div className="flex shrink-0 items-center gap-4">
307
- {hasSystemNoise && (
308
- <button
309
- type="button"
310
- onClick={() => setShowSystemEvents((prev) => !prev)}
311
- className={cn(
312
- "inline-flex shrink-0 cursor-pointer items-center gap-3 rounded-full border px-3.5 py-2 text-sm font-semibold transition-colors",
313
- showSystemEvents
314
- ? "border-foreground bg-foreground text-background shadow-sm hover:bg-foreground/90"
315
- : "border-border bg-background text-muted-foreground shadow-sm hover:bg-muted/40 hover:text-foreground"
316
- )}
317
- aria-pressed={showSystemEvents}
318
- aria-label={toggleHelp}
319
- title={toggleHelp}
320
- data-testid="system-events-toggle"
321
- >
322
- <span
323
- className={cn(
324
- "relative inline-flex h-4 w-8 shrink-0 items-center rounded-full p-0.5 transition-colors",
325
- showSystemEvents ? "bg-teal-600" : "bg-muted-foreground/30"
326
- )}
327
- aria-hidden="true"
328
- data-testid="system-events-indicator"
329
- >
330
- <span
331
- className={cn(
332
- "block h-3 w-3 rounded-full bg-white shadow-sm transition-transform",
333
- showSystemEvents ? "translate-x-4" : "translate-x-0"
334
- )}
335
- />
336
- </span>
337
- <span>{toggleLabel}</span>
338
- {!showSystemEvents ? (
339
- <span
340
- className="inline-flex min-w-[22px] items-center justify-center rounded-full bg-muted px-1.5 text-xs font-bold tabular-nums text-muted-foreground"
341
- data-testid="hidden-count-badge"
342
- >
343
- {hiddenCount}
344
- </span>
345
- ) : null}
346
- </button>
347
- )}
348
-
295
+ {/* Right: system-events toggle always rendered when noise events exist */}
296
+ {hasSystemNoise && (
349
297
  <button
350
298
  type="button"
351
- onClick={() => setShowTimeline((prev) => !prev)}
299
+ onClick={() => setShowSystemEvents((prev) => !prev)}
352
300
  className={cn(
353
- "inline-flex shrink-0 cursor-pointer items-center border-0 bg-transparent p-0 text-muted-foreground transition-colors hover:text-foreground",
354
- isCasePanel ? "gap-3 text-sm" : "gap-1.5 text-[11px]"
301
+ "flex shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-colors hover:text-foreground",
302
+ showSystemEvents
303
+ ? "border-primary/40 bg-primary/10 text-primary shadow-sm hover:bg-primary/15"
304
+ : "border-border bg-background text-muted-foreground hover:bg-muted/40"
355
305
  )}
356
- aria-label={showTimeline ? "Collapse activity timeline" : "Expand activity timeline"}
306
+ aria-pressed={showSystemEvents}
307
+ data-testid="system-events-toggle"
357
308
  >
358
- <span className="font-medium" data-testid="event-count">{eventCountLabel}</span>
359
- <ChevronDown className={`h-3.5 w-3.5 transition-transform duration-200 ${showTimeline ? "rotate-180" : ""}`} />
309
+ {toggleLabel}
310
+ <span
311
+ className={cn(
312
+ "inline-flex min-w-[18px] items-center justify-center rounded-full px-1.5 text-[10px] font-semibold tabular-nums",
313
+ showSystemEvents
314
+ ? "bg-primary/15 text-primary ring-1 ring-primary/30"
315
+ : "bg-muted text-muted-foreground ring-1 ring-border/70"
316
+ )}
317
+ data-testid="hidden-count-badge"
318
+ >
319
+ {hiddenCount}
320
+ </span>
360
321
  </button>
361
- </div>
322
+ )}
362
323
  </div>
363
324
 
364
325
  {/* Timeline body */}
365
326
  {showTimeline && visibleEvents.length > 0 && (
366
- <div className="mt-6">
327
+ <div className="mt-3">
367
328
  <TimelineActivity events={visibleEvents} variant="case-panel" />
368
329
  </div>
369
330
  )}
331
+
332
+ {/* Footer hint */}
333
+ {showTimeline && !showSystemEvents && sysEvtConfig?.hiddenHint && hasSystemNoise && (
334
+ <p className="mt-2 text-[11px] text-muted-foreground/60 border-t border-dashed border-border pt-2" data-testid="timeline-footer-hint">
335
+ {sysEvtConfig.hiddenHint}
336
+ </p>
337
+ )}
338
+ {showTimeline && showSystemEvents && sysEvtConfig?.visibleHint && hasSystemNoise && (
339
+ <p className="mt-2 text-[11px] text-muted-foreground/60 border-t border-dashed border-border pt-2" data-testid="timeline-footer-hint">
340
+ {sysEvtConfig.visibleHint.replace("{count}", String(hiddenCount))}
341
+ </p>
342
+ )}
370
343
  </div>
371
344
  )
372
345
  }
@@ -610,7 +583,6 @@ export function DetailView({
610
583
  attentionCount={attentionCount}
611
584
  sysEvtConfig={sysEvtConfig}
612
585
  lastActivityTime={lastActivityTime}
613
- isCasePanel={isCasePanelV2}
614
586
  />
615
587
  ) : null
616
588