@handled-ai/design-system 0.20.13 → 0.20.15

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.
@@ -25,6 +25,8 @@ export interface CommentComposerProps {
25
25
  /** Current operator (for the avatar). */
26
26
  author?: { name?: string; email?: string; avatarUrl?: string | null }
27
27
  placeholder?: string
28
+ /** Compact spacing for the case-panel timeline surface. Defaults preserve the standard composer sizing. */
29
+ density?: "default" | "case-panel"
28
30
  /** Hint shown in the footer; defaults to the internal-note reassurance. */
29
31
  hint?: string
30
32
  className?: string
@@ -34,6 +36,7 @@ function CommentComposer({
34
36
  onPost,
35
37
  author,
36
38
  placeholder = "Add a comment or internal note…",
39
+ density = "default",
37
40
  hint = "Internal note: only your team sees this",
38
41
  className,
39
42
  }: CommentComposerProps) {
@@ -41,6 +44,8 @@ function CommentComposer({
41
44
  const [focused, setFocused] = React.useState(false)
42
45
  const open = focused || text.length > 0
43
46
  const canPost = text.trim().length > 0
47
+ const hasDraft = text.length > 0
48
+ const compact = density === "case-panel"
44
49
 
45
50
  const post = () => {
46
51
  const value = text.trim()
@@ -55,29 +60,43 @@ function CommentComposer({
55
60
  data-slot="comment-composer"
56
61
  data-open={open ? "true" : undefined}
57
62
  className={cn(
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",
63
+ "flex items-start gap-4 rounded-xl transition-colors",
60
64
  className
61
65
  )}
62
66
  >
63
- <Avatar size="sm" className="mt-px">
67
+ <Avatar size="sm" className="mt-1">
64
68
  {author?.avatarUrl ? <AvatarImage src={author.avatarUrl} alt={author.name ?? "You"} /> : null}
65
- <AvatarFallback className="bg-muted text-muted-foreground text-[10px] font-medium uppercase">
69
+ <AvatarFallback className="bg-slate-700 text-[10px] font-semibold uppercase text-white dark:bg-slate-200 dark:text-slate-900">
66
70
  {getInitials({ name: author?.name, email: author?.email })}
67
71
  </AvatarFallback>
68
72
  </Avatar>
69
73
 
70
- <div className="min-w-0 flex-1">
74
+ <div
75
+ data-slot="comment-composer-shell"
76
+ className={cn(
77
+ "min-w-0 flex-1 rounded-xl border border-border bg-background transition-[box-shadow,border-color]",
78
+ open ? "overflow-hidden shadow-sm" : "shadow-none"
79
+ )}
80
+ >
71
81
  <Textarea
72
82
  data-slot="comment-composer-input"
73
83
  value={text}
74
84
  onChange={(e) => setText(e.target.value)}
75
85
  onFocus={() => setFocused(true)}
76
86
  placeholder={placeholder}
77
- rows={open ? 3 : 1}
87
+ rows={compact ? (hasDraft ? 3 : 1) : (open ? 4 : 1)}
78
88
  className={cn(
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"
89
+ "resize-none rounded-none border-0 bg-transparent px-5 text-[15px] leading-6 shadow-none outline-none placeholder:text-muted-foreground/60 focus-visible:ring-0 focus-visible:ring-offset-0",
90
+ compact ? (open ? "py-4" : "py-3") : "py-4",
91
+ compact
92
+ ? hasDraft
93
+ ? "!min-h-28"
94
+ : open
95
+ ? "!min-h-[76px]"
96
+ : "!min-h-12"
97
+ : open
98
+ ? "min-h-32"
99
+ : "min-h-14"
81
100
  )}
82
101
  onKeyDown={(e) => {
83
102
  if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
@@ -88,15 +107,16 @@ function CommentComposer({
88
107
  />
89
108
 
90
109
  {open ? (
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}
110
+ <div className={cn("flex items-center justify-between gap-3 border-t border-border bg-muted/10 px-5", compact ? "py-3.5" : "py-4")}>
111
+ <span className="inline-flex items-center gap-2 text-sm text-muted-foreground">
112
+ <Lock size={16} strokeWidth={1.75} /> {hint}
94
113
  </span>
95
- <span className="flex items-center gap-2">
114
+ <span className="flex items-center gap-3">
96
115
  <Button
97
116
  type="button"
98
117
  variant="ghost"
99
118
  size="sm"
119
+ className="px-2 text-sm font-medium text-muted-foreground hover:bg-transparent hover:text-foreground"
100
120
  onClick={() => {
101
121
  setText("")
102
122
  setFocused(false)
@@ -104,9 +124,15 @@ function CommentComposer({
104
124
  >
105
125
  Cancel
106
126
  </Button>
107
- <Button type="button" size="sm" disabled={!canPost} onClick={post}>
127
+ <Button
128
+ type="button"
129
+ size="sm"
130
+ disabled={!canPost}
131
+ onClick={post}
132
+ className="rounded-lg bg-foreground px-4 text-sm font-semibold text-background shadow-none hover:bg-foreground/90"
133
+ >
108
134
  Comment
109
- <kbd className="bg-primary-foreground/15 ml-1 rounded px-1 text-[10px]">⌘↵</kbd>
135
+ <kbd className="ml-1 rounded px-1 text-[10px] text-background/70">⌘↵</kbd>
110
136
  </Button>
111
137
  </span>
112
138
  </div>
@@ -1011,14 +1011,21 @@ function ConversationPanel({
1011
1011
  const draft = threads.filter((t) => effectiveStatus(t) === "draft").length
1012
1012
  const awaiting = threads.filter((t) => effectiveStatus(t) === "awaiting").length
1013
1013
  const anyPaused = threads.some((t) => t.paused)
1014
- const hubGmailThread = threads.find((t) => canOpenInGmail(t, onOpenInGmail))
1014
+ const prioritizedThread =
1015
+ threads.find((t) => t.status === "responded" && t.canReply !== false) ??
1016
+ threads.find((t) => effectiveStatus(t) === "draft") ??
1017
+ threads.find((t) => effectiveStatus(t) === "awaiting")
1018
+ const hubGmailThread =
1019
+ threads.find((t) => t.status === "responded" && t.canReply !== false && canOpenInGmail(t, onOpenInGmail)) ??
1020
+ threads.find((t) => effectiveStatus(t) === "draft" && canOpenInGmail(t, onOpenInGmail)) ??
1021
+ threads.find((t) => effectiveStatus(t) === "awaiting" && canOpenInGmail(t, onOpenInGmail)) ??
1022
+ threads.find((t) => canOpenInGmail(t, onOpenInGmail))
1015
1023
  const firstAwaiting = threads.find((t) => effectiveStatus(t) === "awaiting")
1016
1024
 
1017
1025
  const [hubOpen, setHubOpen] = React.useState(true)
1018
1026
  const [openId, setOpenId] = React.useState<string | null>(() => {
1019
1027
  if (defaultOpenThreadId) return defaultOpenThreadId
1020
- const firstActionable = threads.find((t) => ["responded", "draft", "awaiting"].includes(t.status) && t.canReply !== false)
1021
- return firstActionable ? firstActionable.threadId : null
1028
+ return prioritizedThread ? prioritizedThread.threadId : null
1022
1029
  })
1023
1030
 
1024
1031
  if (!threads.length) return null
@@ -1084,10 +1091,10 @@ function ConversationPanel({
1084
1091
  "inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-semibold",
1085
1092
  responded > 0
1086
1093
  ? "bg-status-warning-bg text-status-warning-fg"
1087
- : awaiting > 0
1088
- ? "bg-status-info-bg text-status-info-fg"
1089
- : draft > 0
1090
- ? "bg-status-pending-bg text-status-pending-fg"
1094
+ : draft > 0
1095
+ ? "bg-status-pending-bg text-status-pending-fg"
1096
+ : awaiting > 0
1097
+ ? "bg-status-info-bg text-status-info-fg"
1091
1098
  : "bg-muted text-muted-foreground"
1092
1099
  )}
1093
1100
  >
@@ -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
  }
@@ -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"),
104
+ screen.getByText(/activity timeline/i),
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"),
128
+ screen.getByText(/activity timeline/i),
129
129
  )
130
130
  })
131
131
 
@@ -148,7 +148,25 @@ describe("DetailView case-panel-v2 section layout", () => {
148
148
 
149
149
  expectInDocumentOrder(
150
150
  screen.getByText("Comment composer marker"),
151
- screen.getByText("Activity timeline"),
151
+ screen.getByText(/activity timeline/i),
152
+ )
153
+ })
154
+
155
+ it("adds a consistent stack around the case-panel workflow sections", () => {
156
+ const { container } = renderDetailView({
157
+ sectionLayout: "case-panel-v2",
158
+ renderPrimaryAction: () => <section>Primary action marker</section>,
159
+ renderCommentArea: () => <section>Comment area marker</section>,
160
+ })
161
+
162
+ const stack = container.querySelector('[data-slot="case-panel-workflow-stack"]')
163
+ const commentArea = container.querySelector('[data-slot="case-panel-comment-area"]')
164
+ expect(stack?.className).toContain("space-y-10")
165
+ expect(commentArea).not.toBeNull()
166
+ expectInDocumentOrder(
167
+ screen.getByText("Primary action marker"),
168
+ screen.getByText("Comment area marker"),
169
+ screen.getByText(/activity timeline/i),
152
170
  )
153
171
  })
154
172
 
@@ -142,6 +142,31 @@ describe("DetailView timeline system-events toggle", () => {
142
142
  expect(container.textContent).not.toContain("Score updated -1")
143
143
  })
144
144
 
145
+ it("keeps case-panel system events off by default even when localStorage has a stale visible value", async () => {
146
+ store["test-show-score-changes"] = "true"
147
+ const { container } = render(<DetailView {...baseProps({ sectionLayout: "case-panel-v2" })} />)
148
+ expandTimeline(container)
149
+
150
+ const toggle = container.querySelector('[data-testid="system-events-toggle"]') as HTMLElement
151
+ const eventCount = container.querySelector('[data-testid="event-count"]')
152
+ expect(toggle).toHaveAttribute("aria-pressed", "false")
153
+ expect(eventCount?.textContent).toBe("2 events")
154
+ expect(container.textContent).not.toContain("Score updated +3")
155
+ expect(localStorageMock.setItem).not.toHaveBeenCalledWith("test-show-score-changes", "false")
156
+ })
157
+
158
+ it("uses a shorter, quieter system-events control in the case-panel variant", () => {
159
+ const { container } = render(<DetailView {...baseProps({ sectionLayout: "case-panel-v2" })} />)
160
+ const toggle = container.querySelector('[data-testid="system-events-toggle"]') as HTMLElement
161
+ const indicator = container.querySelector('[data-testid="system-events-indicator"]') as HTMLElement
162
+ const count = container.querySelector('[data-testid="hidden-count-badge"]') as HTMLElement
163
+
164
+ expect(toggle.className).toContain("py-1")
165
+ expect(toggle.className).toContain("text-xs")
166
+ expect(indicator.className).toContain("h-3.5")
167
+ expect(count.className).toContain("text-[11px]")
168
+ })
169
+
145
170
  it("reveals system-noise events when toggle is clicked", () => {
146
171
  const { container } = render(<DetailView {...baseProps()} />)
147
172
  expandTimeline(container)
@@ -209,20 +234,22 @@ describe("DetailView timeline system-events toggle", () => {
209
234
  const badge = container.querySelector('[data-testid="hidden-count-badge"]')
210
235
  expect(badge).not.toBeNull()
211
236
  expect(badge?.textContent).toBe("2")
212
- expect(badge).toHaveClass("min-w-[18px]")
237
+ expect(badge).toHaveClass("min-w-[22px]")
213
238
  })
214
239
 
215
- it("calls localStorage.setItem when toggle changes and shows a stronger pressed style", () => {
240
+ it("calls localStorage.setItem when toggle changes and shows the active pill style", () => {
216
241
  const { container } = render(<DetailView {...baseProps()} />)
217
242
  expandTimeline(container)
218
243
  const toggle = container.querySelector(
219
244
  '[data-testid="system-events-toggle"]',
220
245
  ) as HTMLElement
221
246
  expect(toggle).toHaveAttribute("aria-pressed", "false")
247
+ expect(toggle).toHaveAttribute("title", "Score changes are hidden.")
222
248
  fireEvent.click(toggle)
223
249
  expect(toggle).toHaveAttribute("aria-pressed", "true")
224
- expect(toggle.className).toContain("border-primary/40")
225
- expect(toggle.className).toContain("bg-primary/10")
250
+ expect(toggle).toHaveClass("border-foreground")
251
+ expect(toggle).toHaveClass("bg-foreground")
252
+ expect(toggle).toHaveAttribute("title", "Showing 2 score changes.")
226
253
  expect(localStorageMock.setItem).toHaveBeenCalledWith(
227
254
  "test-show-score-changes",
228
255
  "true",
@@ -312,18 +339,19 @@ describe("DetailView timeline system-events toggle", () => {
312
339
  expect(toggle).toBeNull()
313
340
  })
314
341
 
315
- it("shows footer hint below the case-panel timeline when timeline is expanded and system events are hidden", () => {
342
+ it("does not render a footer hint and uses the hidden hint as toggle help", () => {
316
343
  const { container } = render(<DetailView {...baseProps()} />)
317
344
  expandTimeline(container)
318
345
  const timeline = container.querySelector('[data-variant="case-panel"]')
319
346
  const hint = container.querySelector('[data-testid="timeline-footer-hint"]')
347
+ const toggle = container.querySelector('[data-testid="system-events-toggle"]')
320
348
  expect(timeline).not.toBeNull()
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)
349
+ expect(hint).toBeNull()
350
+ expect(toggle).toHaveAttribute("title", "Score changes are hidden.")
351
+ expect(toggle).toHaveAttribute("aria-label", "Score changes are hidden.")
324
352
  })
325
353
 
326
- it("shows visible footer hint with count when system events are shown", () => {
354
+ it("uses visible footer hint text as toggle help with count when system events are shown", () => {
327
355
  const { container } = render(<DetailView {...baseProps()} />)
328
356
  expandTimeline(container)
329
357
  // Toggle on
@@ -332,8 +360,8 @@ describe("DetailView timeline system-events toggle", () => {
332
360
  ) as HTMLElement
333
361
  fireEvent.click(toggle)
334
362
  const hint = container.querySelector('[data-testid="timeline-footer-hint"]')
335
- expect(hint).not.toBeNull()
336
- expect(hint?.textContent).toBe("Showing 2 score changes.")
363
+ expect(hint).toBeNull()
364
+ expect(toggle).toHaveAttribute("title", "Showing 2 score changes.")
337
365
  })
338
366
 
339
367
  // --- Toggle always renders when system-noise events exist (review fix #1) ---
@@ -414,10 +442,10 @@ describe("DetailView timeline system-events toggle", () => {
414
442
  const toggle = container.querySelector('[data-testid="system-events-toggle"]')
415
443
  expect(toggle).not.toBeNull()
416
444
  expect(toggle?.textContent).toContain("Legacy label")
417
- // Footer hint should work too
445
+ // Deprecated hint props are accepted and exposed as toggle help.
418
446
  expandTimeline(container)
419
447
  const hint = container.querySelector('[data-testid="timeline-footer-hint"]')
420
- expect(hint).not.toBeNull()
421
- expect(hint?.textContent).toBe("Legacy hidden hint.")
448
+ expect(hint).toBeNull()
449
+ expect(toggle).toHaveAttribute("title", "Legacy hidden hint.")
422
450
  })
423
451
  })
@@ -222,6 +222,7 @@ function TimelineSection({
222
222
  attentionCount,
223
223
  sysEvtConfig,
224
224
  lastActivityTime,
225
+ isCasePanel = false,
225
226
  }: {
226
227
  timelineEvents: TimelineEvent[]
227
228
  showTimeline: boolean
@@ -231,6 +232,7 @@ function TimelineSection({
231
232
  attentionCount?: number
232
233
  sysEvtConfig?: TimelineSystemEventsConfig
233
234
  lastActivityTime?: string
235
+ isCasePanel?: boolean
234
236
  }) {
235
237
  // Single-pass partition: compute visibleEvents and hiddenCount together
236
238
  const visibleEvents: TimelineEvent[] = []
@@ -245,6 +247,9 @@ function TimelineSection({
245
247
  // config was provided — so consumers that emit `isSystemNoise: true` always
246
248
  // give users a way to reveal those events.
247
249
  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"
248
253
 
249
254
  // Derive "Last activity" from the first *visible* event so the collapsed
250
255
  // header never points at a hidden score-update. The caller-supplied
@@ -262,84 +267,111 @@ function TimelineSection({
262
267
  const eventCountLabel = `${visibleCount} ${visibleCount === 1 ? "event" : "events"}`
263
268
 
264
269
  return (
265
- <div className="mb-8">
270
+ <div
271
+ className={cn(
272
+ isCasePanel ? "mt-8 border-t border-border pt-8 pb-8" : "mb-8"
273
+ )}
274
+ >
266
275
  {/* Header — outer non-interactive container */}
267
276
  <div
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"
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
+ )}
269
283
  data-testid="timeline-header"
270
284
  >
271
285
  {/* Left: collapse/expand button */}
272
286
  <button
273
287
  type="button"
274
288
  onClick={() => setShowTimeline((prev) => !prev)}
275
- className="flex items-center gap-2 cursor-pointer bg-transparent border-0 p-0"
289
+ className="flex min-w-0 cursor-pointer items-center gap-2 border-0 bg-transparent p-0 text-left"
276
290
  data-testid="timeline-collapse-btn"
277
291
  >
278
- <h3 className="text-xs font-bold text-muted-foreground uppercase tracking-wider group-hover/timeline:text-foreground transition-colors">Activity timeline</h3>
292
+ <h3 className="text-xs font-bold uppercase tracking-[0.16em] text-muted-foreground transition-colors group-hover/timeline:text-foreground">ACTIVITY TIMELINE</h3>
279
293
  {!showTimeline && attentionCount != null && attentionCount > 0 && (
280
294
  <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">
281
295
  {attentionCount} new
282
296
  </span>
283
297
  )}
284
- {!showTimeline && firstVisibleTime && (
298
+ {!isCasePanel && !showTimeline && firstVisibleTime && (
285
299
  <span className="text-[11px] text-muted-foreground/60" data-testid="last-activity-hint">
286
300
  &middot; Last activity {firstVisibleTime}
287
301
  </span>
288
302
  )}
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>
293
303
  </button>
294
304
 
295
- {/* Right: system-events toggle always rendered when noise events exist */}
296
- {hasSystemNoise && (
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 rounded-full border transition-colors",
313
+ isCasePanel ? "gap-2 px-2.5 py-1 text-xs font-medium" : "gap-3 px-3.5 py-2 text-sm font-semibold",
314
+ showSystemEvents
315
+ ? isCasePanel
316
+ ? "border-border bg-muted text-foreground shadow-sm hover:bg-muted/80"
317
+ : "border-foreground bg-foreground text-background shadow-sm hover:bg-foreground/90"
318
+ : "border-border bg-background text-muted-foreground shadow-sm hover:bg-muted/40 hover:text-foreground"
319
+ )}
320
+ aria-pressed={showSystemEvents}
321
+ aria-label={toggleHelp}
322
+ title={toggleHelp}
323
+ data-testid="system-events-toggle"
324
+ >
325
+ <span
326
+ className={cn(
327
+ "relative inline-flex shrink-0 items-center rounded-full p-0.5 transition-colors",
328
+ isCasePanel ? "h-3.5 w-7" : "h-4 w-8",
329
+ showSystemEvents ? "bg-teal-600" : "bg-muted-foreground/25"
330
+ )}
331
+ aria-hidden="true"
332
+ data-testid="system-events-indicator"
333
+ >
334
+ <span
335
+ className={cn(
336
+ "block rounded-full bg-white shadow-sm transition-transform",
337
+ isCasePanel ? "h-2.5 w-2.5" : "h-3 w-3",
338
+ showSystemEvents ? (isCasePanel ? "translate-x-3.5" : "translate-x-4") : "translate-x-0"
339
+ )}
340
+ />
341
+ </span>
342
+ <span>{toggleLabel}</span>
343
+ {!showSystemEvents ? (
344
+ <span
345
+ className={cn("inline-flex items-center justify-center rounded-full bg-muted px-1.5 font-bold tabular-nums text-muted-foreground", isCasePanel ? "min-w-5 text-[11px]" : "min-w-[22px] text-xs")}
346
+ data-testid="hidden-count-badge"
347
+ >
348
+ {hiddenCount}
349
+ </span>
350
+ ) : null}
351
+ </button>
352
+ )}
353
+
297
354
  <button
298
355
  type="button"
299
- onClick={() => setShowSystemEvents((prev) => !prev)}
356
+ onClick={() => setShowTimeline((prev) => !prev)}
300
357
  className={cn(
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"
358
+ "inline-flex shrink-0 cursor-pointer items-center border-0 bg-transparent p-0 text-muted-foreground transition-colors hover:text-foreground",
359
+ isCasePanel ? "gap-3 text-sm" : "gap-1.5 text-[11px]"
305
360
  )}
306
- aria-pressed={showSystemEvents}
307
- data-testid="system-events-toggle"
361
+ aria-label={showTimeline ? "Collapse activity timeline" : "Expand activity timeline"}
308
362
  >
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>
363
+ <span className="font-medium" data-testid="event-count">{eventCountLabel}</span>
364
+ <ChevronDown className={`h-3.5 w-3.5 transition-transform duration-200 ${showTimeline ? "rotate-180" : ""}`} />
321
365
  </button>
322
- )}
366
+ </div>
323
367
  </div>
324
368
 
325
369
  {/* Timeline body */}
326
370
  {showTimeline && visibleEvents.length > 0 && (
327
- <div className="mt-3">
371
+ <div className="mt-6">
328
372
  <TimelineActivity events={visibleEvents} variant="case-panel" />
329
373
  </div>
330
374
  )}
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
- )}
343
375
  </div>
344
376
  )
345
377
  }
@@ -423,6 +455,8 @@ export function DetailView({
423
455
  timelineSystemEventsVisibleHint,
424
456
  ])
425
457
 
458
+ const isCasePanelV2 = sectionLayout === "case-panel-v2"
459
+
426
460
  const [showTimeline, setShowTimeline] = React.useState(false)
427
461
  const [extraActions, setExtraActions] = React.useState<SuggestedAction[]>([])
428
462
 
@@ -432,9 +466,12 @@ export function DetailView({
432
466
  const [showSystemEvents, setShowSystemEvents] = React.useState(sysEvtDefaultVisible)
433
467
  const initialReadDoneRef = React.useRef(false)
434
468
 
435
- // Read persisted value from localStorage on mount
469
+ // Read persisted value from localStorage on mount. Case-panel timelines always
470
+ // start from the configured default so stale local state cannot make system
471
+ // events prominent by default in the inbox detail panel.
436
472
  React.useEffect(() => {
437
- if (!sysEvtStorageKey) {
473
+ if (!sysEvtStorageKey || isCasePanelV2) {
474
+ setShowSystemEvents(sysEvtDefaultVisible)
438
475
  initialReadDoneRef.current = true
439
476
  return
440
477
  }
@@ -442,23 +479,27 @@ export function DetailView({
442
479
  const stored = localStorage.getItem(sysEvtStorageKey)
443
480
  if (stored !== null) {
444
481
  setShowSystemEvents(stored === "true")
482
+ } else {
483
+ setShowSystemEvents(sysEvtDefaultVisible)
445
484
  }
446
485
  } catch {
447
486
  // localStorage unavailable — ignore
487
+ setShowSystemEvents(sysEvtDefaultVisible)
448
488
  }
449
489
  initialReadDoneRef.current = true
450
- }, [sysEvtStorageKey])
490
+ }, [isCasePanelV2, sysEvtDefaultVisible, sysEvtStorageKey])
451
491
 
452
- // Write to localStorage when the toggle changes (skip initial if matching default)
492
+ // Write to localStorage when the toggle changes (skip case-panel timelines so
493
+ // they stay off by default on the next case/session).
453
494
  React.useEffect(() => {
454
- if (!sysEvtStorageKey) return
495
+ if (!sysEvtStorageKey || isCasePanelV2) return
455
496
  if (!initialReadDoneRef.current) return
456
497
  try {
457
498
  localStorage.setItem(sysEvtStorageKey, String(showSystemEvents))
458
499
  } catch {
459
500
  // localStorage unavailable — ignore
460
501
  }
461
- }, [showSystemEvents, sysEvtStorageKey])
502
+ }, [isCasePanelV2, showSystemEvents, sysEvtStorageKey])
462
503
 
463
504
  React.useEffect(() => {
464
505
  setShowTimeline(false)
@@ -501,8 +542,6 @@ export function DetailView({
501
542
  ? "border-amber-300 bg-amber-50 text-amber-700 hover:bg-amber-50"
502
543
  : "hover:bg-muted/50"
503
544
 
504
- const isCasePanelV2 = sectionLayout === "case-panel-v2"
505
-
506
545
  // The metadata chips row (priority · deadline · account · renderMetadataExtra). Rendered above
507
546
  // the brief by default, or beneath it when `metadataLayout === "below-brief"` (case-panel redesign).
508
547
  const metadataChips = (
@@ -583,6 +622,7 @@ export function DetailView({
583
622
  attentionCount={attentionCount}
584
623
  sysEvtConfig={sysEvtConfig}
585
624
  lastActivityTime={lastActivityTime}
625
+ isCasePanel={isCasePanelV2}
586
626
  />
587
627
  ) : null
588
628
 
@@ -734,10 +774,16 @@ export function DetailView({
734
774
  </>
735
775
  ) : null}
736
776
 
737
- {/* After-score content slot (e.g. OpportunityPanel) */}
738
- {renderAfterScore?.(item)}
739
- {renderPrimaryAction?.(item)}
740
- {renderCommentArea?.(item)}
777
+ <div data-slot="case-panel-workflow-stack" className="space-y-10">
778
+ {/* After-score content slot (e.g. OpportunityPanel) */}
779
+ {renderAfterScore?.(item)}
780
+ {renderPrimaryAction?.(item)}
781
+ {renderCommentArea ? (
782
+ <div data-slot="case-panel-comment-area">
783
+ {renderCommentArea(item)}
784
+ </div>
785
+ ) : null}
786
+ </div>
741
787
  {timelineSection}
742
788
  </>
743
789
  ) : (