@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.
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/comment-composer.d.ts +3 -1
- package/dist/components/comment-composer.js +73 -49
- package/dist/components/comment-composer.js.map +1 -1
- package/dist/components/conversation-panel.js +5 -4
- package/dist/components/conversation-panel.js.map +1 -1
- package/dist/components/email-body.js +39 -1
- package/dist/components/email-body.js.map +1 -1
- package/dist/components/pill.d.ts +1 -1
- package/dist/components/tabs.d.ts +1 -1
- package/dist/prototype/prototype-inbox-view.js +124 -70
- package/dist/prototype/prototype-inbox-view.js.map +1 -1
- package/package.json +1 -1
- package/src/components/__tests__/comment-composer.test.tsx +22 -3
- package/src/components/__tests__/conversation-panel.test.tsx +118 -0
- package/src/components/__tests__/email-body.test.tsx +32 -0
- package/src/components/comment-composer.tsx +40 -14
- package/src/components/conversation-panel.tsx +14 -7
- package/src/components/email-body.tsx +46 -1
- package/src/prototype/__tests__/detail-view-case-panel-v2.test.tsx +21 -3
- package/src/prototype/__tests__/detail-view-timeline-system-events.test.tsx +42 -14
- package/src/prototype/prototype-inbox-view.tsx +102 -56
|
@@ -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
|
-
"
|
|
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-
|
|
67
|
+
<Avatar size="sm" className="mt-1">
|
|
64
68
|
{author?.avatarUrl ? <AvatarImage src={author.avatarUrl} alt={author.name ?? "You"} /> : null}
|
|
65
|
-
<AvatarFallback className="bg-
|
|
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
|
|
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={
|
|
87
|
+
rows={compact ? (hasDraft ? 3 : 1) : (open ? 4 : 1)}
|
|
78
88
|
className={cn(
|
|
79
|
-
"resize-none border-0 bg-transparent px-
|
|
80
|
-
|
|
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="
|
|
92
|
-
<span className="
|
|
93
|
-
<Lock size={
|
|
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-
|
|
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
|
|
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="
|
|
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
|
|
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
|
-
|
|
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
|
-
:
|
|
1088
|
-
? "bg-status-
|
|
1089
|
-
:
|
|
1090
|
-
? "bg-status-
|
|
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(
|
|
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(
|
|
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(
|
|
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-[
|
|
237
|
+
expect(badge).toHaveClass("min-w-[22px]")
|
|
213
238
|
})
|
|
214
239
|
|
|
215
|
-
it("calls localStorage.setItem when toggle changes and shows
|
|
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
|
|
225
|
-
expect(toggle
|
|
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("
|
|
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).
|
|
322
|
-
expect(
|
|
323
|
-
expect(
|
|
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("
|
|
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).
|
|
336
|
-
expect(
|
|
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
|
-
//
|
|
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).
|
|
421
|
-
expect(
|
|
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
|
|
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=
|
|
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
|
|
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
|
|
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
|
· 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
|
|
296
|
-
|
|
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={() =>
|
|
356
|
+
onClick={() => setShowTimeline((prev) => !prev)}
|
|
300
357
|
className={cn(
|
|
301
|
-
"flex shrink-0 cursor-pointer items-center
|
|
302
|
-
|
|
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-
|
|
307
|
-
data-testid="system-events-toggle"
|
|
361
|
+
aria-label={showTimeline ? "Collapse activity timeline" : "Expand activity timeline"}
|
|
308
362
|
>
|
|
309
|
-
{
|
|
310
|
-
<
|
|
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-
|
|
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
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
) : (
|