@handled-ai/design-system 0.20.12 → 0.20.14
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/conversation-panel.js +129 -85
- 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/package.json +1 -1
- package/src/components/__tests__/conversation-panel.test.tsx +193 -11
- package/src/components/__tests__/email-body.test.tsx +32 -0
- package/src/components/conversation-panel.tsx +90 -29
- package/src/components/email-body.tsx +46 -1
|
@@ -57,14 +57,77 @@ describe("ConversationPanel", () => {
|
|
|
57
57
|
expect(container.querySelector('[data-slot="conversation-panel"]')).toBeNull();
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
it("shows the
|
|
61
|
-
render(<ConversationPanel threads={[thread()]} me={me} />);
|
|
60
|
+
it("shows the amber response-detected treatment when a thread has responded", () => {
|
|
61
|
+
const { container } = render(<ConversationPanel threads={[thread({ paused: { playbook: "Mercury Renewal Save" } })]} me={me} />);
|
|
62
|
+
|
|
63
|
+
const panel = container.querySelector('[data-slot="conversation-panel"]')!;
|
|
64
|
+
const header = container.querySelector('[data-slot="conversation-panel-header"]')!;
|
|
65
|
+
const row = container.querySelector('[data-slot="conv-thread"]')!;
|
|
66
|
+
|
|
67
|
+
expect(panel.getAttribute("data-state")).toBe("responded");
|
|
68
|
+
expect(panel.className).toContain("border-status-warning-border");
|
|
69
|
+
expect(header.className).toContain("bg-status-warning-bg");
|
|
70
|
+
expect(row.getAttribute("data-status")).toBe("responded");
|
|
71
|
+
expect(row.className).toContain("border-l-status-warning-border");
|
|
62
72
|
expect(screen.getByText("Email response detected")).toBeDefined();
|
|
73
|
+
expect(screen.getByText("1 reply needs your response")).toBeDefined();
|
|
74
|
+
expect(screen.getByText("NEW REPLY")).toBeDefined();
|
|
75
|
+
expect(screen.getAllByText(/Playbook stopped/i).length).toBeGreaterThanOrEqual(1);
|
|
63
76
|
});
|
|
64
77
|
|
|
65
|
-
it("shows the
|
|
66
|
-
|
|
67
|
-
|
|
78
|
+
it("shows the blue sent/awaiting treatment, receipt chips, and hub Gmail action", () => {
|
|
79
|
+
const { container } = render(
|
|
80
|
+
<ConversationPanel
|
|
81
|
+
threads={[
|
|
82
|
+
thread({
|
|
83
|
+
status: "awaiting",
|
|
84
|
+
openInGmailUrl: "https://mail.google.com/mail/u/0/#sent/thread-mercury",
|
|
85
|
+
messages: [
|
|
86
|
+
{
|
|
87
|
+
id: "sent-1",
|
|
88
|
+
direction: "outbound",
|
|
89
|
+
from: me,
|
|
90
|
+
to: priya,
|
|
91
|
+
date: "Jun 8, 2026, 9:12 AM",
|
|
92
|
+
receipt: { kind: "sent", label: "Sent" },
|
|
93
|
+
body: "Hi Priya — looping back on the Mercury prior authorization workflow.",
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: "opened-1",
|
|
97
|
+
direction: "outbound",
|
|
98
|
+
from: me,
|
|
99
|
+
to: priya,
|
|
100
|
+
date: "Jun 8, 2026, 9:24 AM",
|
|
101
|
+
receipt: { kind: "opened", label: "Opened" },
|
|
102
|
+
body: "Sharing the implementation notes again so they stay on this thread.",
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
}),
|
|
106
|
+
]}
|
|
107
|
+
me={me}
|
|
108
|
+
/>,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const panel = container.querySelector('[data-slot="conversation-panel"]')!;
|
|
112
|
+
const header = container.querySelector('[data-slot="conversation-panel-header"]')!;
|
|
113
|
+
const row = container.querySelector('[data-slot="conv-thread"]')!;
|
|
114
|
+
|
|
115
|
+
expect(panel.getAttribute("data-state")).toBe("awaiting");
|
|
116
|
+
expect(panel.className).toContain("border-status-info-border");
|
|
117
|
+
expect(header.className).toContain("bg-status-info-bg");
|
|
118
|
+
expect(row.getAttribute("data-status")).toBe("awaiting");
|
|
119
|
+
expect(row.className).toContain("border-l-status-info-border");
|
|
120
|
+
expect(screen.getByText("Email sent · awaiting reply")).toBeDefined();
|
|
121
|
+
expect(screen.getByText("Awaiting a response from Priya")).toBeDefined();
|
|
122
|
+
expect(screen.getByText("SENT")).toBeDefined();
|
|
123
|
+
expect(screen.getByText("Opened")).toBeDefined();
|
|
124
|
+
|
|
125
|
+
fireEvent.click(screen.getByText(/Hi Priya — looping back/i));
|
|
126
|
+
expect(screen.getByText("Sent")).toBeDefined();
|
|
127
|
+
|
|
128
|
+
const gmailLinks = screen.getAllByRole("link", { name: /Open in Gmail/ });
|
|
129
|
+
expect(gmailLinks.length).toBeGreaterThanOrEqual(1);
|
|
130
|
+
expect(gmailLinks[0].getAttribute("href")).toBe("https://mail.google.com/mail/u/0/#sent/thread-mercury");
|
|
68
131
|
});
|
|
69
132
|
|
|
70
133
|
|
|
@@ -93,12 +156,130 @@ describe("ConversationPanel", () => {
|
|
|
93
156
|
|
|
94
157
|
expect(screen.getByText("Draft ready")).toBeDefined();
|
|
95
158
|
expect(screen.getByText("Draft ready on 1 thread")).toBeDefined();
|
|
96
|
-
expect(screen.queryByText("
|
|
159
|
+
expect(screen.queryByText("Email sent · awaiting reply")).toBeNull();
|
|
97
160
|
expect(screen.getAllByText("Draft").length).toBeGreaterThan(0);
|
|
98
161
|
expect(screen.getByText("Draft copy")).toBeDefined();
|
|
99
162
|
});
|
|
100
163
|
|
|
101
164
|
|
|
165
|
+
|
|
166
|
+
it("prioritizes responded threads for the open row and header Gmail action", () => {
|
|
167
|
+
render(
|
|
168
|
+
<ConversationPanel
|
|
169
|
+
threads={[
|
|
170
|
+
thread({
|
|
171
|
+
threadId: "awaiting-thread",
|
|
172
|
+
status: "awaiting",
|
|
173
|
+
openInGmailUrl: "https://mail.google.com/mail/u/0/#sent/awaiting-thread",
|
|
174
|
+
messages: [
|
|
175
|
+
{
|
|
176
|
+
id: "sent-1",
|
|
177
|
+
direction: "outbound",
|
|
178
|
+
from: me,
|
|
179
|
+
to: priya,
|
|
180
|
+
date: "Today",
|
|
181
|
+
receipt: { kind: "sent", label: "Sent" },
|
|
182
|
+
body: "Awaiting response",
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
}),
|
|
186
|
+
thread({
|
|
187
|
+
threadId: "responded-without-gmail-thread",
|
|
188
|
+
status: "responded",
|
|
189
|
+
openInGmailUrl: undefined,
|
|
190
|
+
messages: [
|
|
191
|
+
{
|
|
192
|
+
id: "reply-1",
|
|
193
|
+
direction: "inbound",
|
|
194
|
+
from: priya,
|
|
195
|
+
to: me,
|
|
196
|
+
date: "Today",
|
|
197
|
+
receipt: { kind: "new", label: "Reply" },
|
|
198
|
+
body: "Can you send the updated numbers?",
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
}),
|
|
202
|
+
thread({
|
|
203
|
+
threadId: "responded-thread",
|
|
204
|
+
status: "responded",
|
|
205
|
+
openInGmailUrl: "https://mail.google.com/mail/u/0/#inbox/responded-thread",
|
|
206
|
+
messages: [
|
|
207
|
+
{
|
|
208
|
+
id: "reply-2",
|
|
209
|
+
direction: "inbound",
|
|
210
|
+
from: priya,
|
|
211
|
+
to: me,
|
|
212
|
+
date: "Today",
|
|
213
|
+
receipt: { kind: "new", label: "Reply" },
|
|
214
|
+
body: "Following up with the numbers.",
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
}),
|
|
218
|
+
]}
|
|
219
|
+
me={me}
|
|
220
|
+
/>,
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
expect(screen.getByText("Email response detected")).toBeDefined();
|
|
224
|
+
expect(screen.getByText("2 replies need your response")).toBeDefined();
|
|
225
|
+
|
|
226
|
+
const rows = document.querySelectorAll('[data-slot="conv-thread"]');
|
|
227
|
+
expect(rows[0].getAttribute("data-open")).toBeNull();
|
|
228
|
+
expect(rows[1].getAttribute("data-open")).toBe("true");
|
|
229
|
+
expect(rows[2].getAttribute("data-open")).toBeNull();
|
|
230
|
+
|
|
231
|
+
const gmailLinks = screen.getAllByRole("link", { name: /Open in Gmail/ });
|
|
232
|
+
expect(gmailLinks[0].getAttribute("href")).toBe("https://mail.google.com/mail/u/0/#inbox/responded-thread");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("keeps draft-ready badge styling when draft and awaiting threads are mixed", () => {
|
|
236
|
+
const { container } = render(
|
|
237
|
+
<ConversationPanel
|
|
238
|
+
threads={[
|
|
239
|
+
thread({
|
|
240
|
+
threadId: "awaiting-thread",
|
|
241
|
+
status: "awaiting",
|
|
242
|
+
messages: [
|
|
243
|
+
{
|
|
244
|
+
id: "sent-1",
|
|
245
|
+
direction: "outbound",
|
|
246
|
+
from: me,
|
|
247
|
+
to: priya,
|
|
248
|
+
date: "Today",
|
|
249
|
+
receipt: { kind: "sent", label: "Sent" },
|
|
250
|
+
body: "Awaiting response",
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
}),
|
|
254
|
+
thread({
|
|
255
|
+
threadId: "draft-thread",
|
|
256
|
+
status: "draft",
|
|
257
|
+
messages: [
|
|
258
|
+
{
|
|
259
|
+
id: "draft-1",
|
|
260
|
+
direction: "outbound",
|
|
261
|
+
from: me,
|
|
262
|
+
to: priya,
|
|
263
|
+
date: "Today",
|
|
264
|
+
receipt: { kind: "draft", label: "Draft" },
|
|
265
|
+
body: "Draft copy",
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
}),
|
|
269
|
+
]}
|
|
270
|
+
me={me}
|
|
271
|
+
/>,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
expect(screen.getByText("Draft ready")).toBeDefined();
|
|
275
|
+
expect(screen.getByText("Draft ready on 1 thread")).toBeDefined();
|
|
276
|
+
|
|
277
|
+
const badge = container.querySelector('[data-slot="conversation-badge"]')!;
|
|
278
|
+
expect(badge.className).toContain("bg-status-pending-bg");
|
|
279
|
+
expect(badge.className).toContain("text-status-pending-fg");
|
|
280
|
+
expect(badge.className).not.toContain("bg-status-info-bg");
|
|
281
|
+
});
|
|
282
|
+
|
|
102
283
|
it("uses the custom read-only reason and disables Open in Gmail when access is unavailable", () => {
|
|
103
284
|
const onOpenInGmail = vi.fn();
|
|
104
285
|
|
|
@@ -140,10 +321,11 @@ describe("ConversationPanel", () => {
|
|
|
140
321
|
/>,
|
|
141
322
|
);
|
|
142
323
|
|
|
143
|
-
const
|
|
144
|
-
expect(
|
|
145
|
-
expect(
|
|
146
|
-
expect(
|
|
324
|
+
const links = screen.getAllByRole("link", { name: /Open in Gmail/ });
|
|
325
|
+
expect(links.length).toBeGreaterThanOrEqual(1);
|
|
326
|
+
expect(links[0].getAttribute("href")).toBe("https://mail.google.com/mail/?authuser=dana%40handled.ai#drafts/msg-1");
|
|
327
|
+
expect(links[0].getAttribute("target")).toBe("_blank");
|
|
328
|
+
expect(links[0].getAttribute("rel")).toBe("noopener noreferrer");
|
|
147
329
|
});
|
|
148
330
|
|
|
149
331
|
it("auto-opens the first responded thread and renders its newest message as HTML", () => {
|
|
@@ -512,7 +694,7 @@ describe("ConversationPanel", () => {
|
|
|
512
694
|
tenantName="Mercury OS"
|
|
513
695
|
/>,
|
|
514
696
|
);
|
|
515
|
-
expect(screen.
|
|
697
|
+
expect(screen.getAllByText(/Playbook stopped/i).length).toBeGreaterThanOrEqual(1);
|
|
516
698
|
});
|
|
517
699
|
|
|
518
700
|
it("opens the reply composer when Reply is clicked", () => {
|
|
@@ -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
|
})
|
|
@@ -298,19 +298,34 @@ function PersonAvatar({ person, size = "sm" }: { person: ConvParticipant; size?:
|
|
|
298
298
|
}
|
|
299
299
|
|
|
300
300
|
const STATUS_PILL: Record<ConvStatus, { label: string; cls: string }> = {
|
|
301
|
-
responded: { label: "
|
|
301
|
+
responded: { label: "NEW REPLY", cls: "bg-status-warning-bg text-status-warning-fg border-status-warning-border" },
|
|
302
302
|
draft: { label: "Draft", cls: "bg-background text-foreground/80 border-border" },
|
|
303
|
-
awaiting: { label: "
|
|
303
|
+
awaiting: { label: "SENT", cls: "bg-status-info-bg text-status-info-fg border-status-info-border" },
|
|
304
304
|
viewing: { label: "Viewing", cls: "bg-muted text-muted-foreground border-border" },
|
|
305
305
|
}
|
|
306
306
|
|
|
307
307
|
const STATUS_DOT: Record<ConvStatus, string> = {
|
|
308
|
-
responded: "bg-status-
|
|
308
|
+
responded: "bg-status-warning-fg",
|
|
309
309
|
draft: "bg-status-pending-fg",
|
|
310
|
-
awaiting: "bg-status-
|
|
310
|
+
awaiting: "bg-status-info-fg",
|
|
311
311
|
viewing: "bg-muted-foreground/50",
|
|
312
312
|
}
|
|
313
313
|
|
|
314
|
+
const THREAD_ROW_ACCENT: Record<ConvStatus, string> = {
|
|
315
|
+
responded: "border-l-4 border-l-status-warning-border bg-status-warning-bg/25",
|
|
316
|
+
awaiting: "border-l-4 border-l-status-info-border bg-status-info-bg/25",
|
|
317
|
+
draft: "border-l-4 border-l-status-pending-border bg-status-pending-bg/20",
|
|
318
|
+
viewing: "",
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const RECEIPT_CHIP: Record<NonNullable<ConvMessage["receipt"]>["kind"], string> = {
|
|
322
|
+
new: "border-status-warning-border bg-status-warning-bg text-status-warning-fg",
|
|
323
|
+
read: "border-status-info-border bg-status-info-bg text-status-info-fg",
|
|
324
|
+
opened: "border-status-info-border bg-status-info-bg text-status-info-fg",
|
|
325
|
+
sent: "border-status-info-border bg-status-info-bg text-status-info-fg",
|
|
326
|
+
draft: "border-status-pending-border bg-status-pending-bg text-status-pending-fg",
|
|
327
|
+
}
|
|
328
|
+
|
|
314
329
|
function effectiveStatus(t: ConversationThread): ConvStatus {
|
|
315
330
|
return t.canReply === false ? "viewing" : t.status
|
|
316
331
|
}
|
|
@@ -432,10 +447,10 @@ function MessageView({
|
|
|
432
447
|
</span>
|
|
433
448
|
<span className="flex shrink-0 items-center gap-2">
|
|
434
449
|
{message.receipt ? (
|
|
435
|
-
<span className="
|
|
450
|
+
<span className={cn("inline-flex items-center gap-1 rounded-md border px-1.5 py-px text-[10px] font-semibold leading-4", RECEIPT_CHIP[message.receipt.kind])}>
|
|
436
451
|
{message.receipt.kind === "new" ? (
|
|
437
452
|
<CornerUpLeft size={11} />
|
|
438
|
-
) : message.receipt.kind === "read" ? (
|
|
453
|
+
) : message.receipt.kind === "read" || message.receipt.kind === "sent" ? (
|
|
439
454
|
<CheckCheck size={11} />
|
|
440
455
|
) : message.receipt.kind === "draft" ? (
|
|
441
456
|
<FilePenLine size={11} />
|
|
@@ -811,10 +826,10 @@ function ThreadBody({
|
|
|
811
826
|
return (
|
|
812
827
|
<div data-slot="conv-thread-body" className="space-y-2">
|
|
813
828
|
{canReply && thread.paused ? (
|
|
814
|
-
<div className="border-status-
|
|
829
|
+
<div className="border-status-warning-border bg-status-warning-bg text-status-warning-fg flex items-start gap-2 rounded-md border border-l-4 p-2.5 text-[12px]">
|
|
815
830
|
<Pause size={13} className="mt-0.5 shrink-0" />
|
|
816
831
|
<span>
|
|
817
|
-
<b>Follow-up actions
|
|
832
|
+
<b>Playbook stopped.</b> Follow-up actions for {thread.paused.playbook} won’t send
|
|
818
833
|
automatically while this conversation is live. Continue it in {tenantName ?? "the app"} or Gmail.
|
|
819
834
|
</span>
|
|
820
835
|
</div>
|
|
@@ -930,7 +945,12 @@ function ThreadRow({
|
|
|
930
945
|
const pill = STATUS_PILL[status]
|
|
931
946
|
|
|
932
947
|
return (
|
|
933
|
-
<div
|
|
948
|
+
<div
|
|
949
|
+
data-slot="conv-thread"
|
|
950
|
+
data-status={status}
|
|
951
|
+
data-open={open ? "true" : undefined}
|
|
952
|
+
className={cn("border-border border-b last:border-b-0", THREAD_ROW_ACCENT[status])}
|
|
953
|
+
>
|
|
934
954
|
<button
|
|
935
955
|
type="button"
|
|
936
956
|
onClick={onToggleOpen}
|
|
@@ -991,12 +1011,21 @@ function ConversationPanel({
|
|
|
991
1011
|
const draft = threads.filter((t) => effectiveStatus(t) === "draft").length
|
|
992
1012
|
const awaiting = threads.filter((t) => effectiveStatus(t) === "awaiting").length
|
|
993
1013
|
const anyPaused = threads.some((t) => t.paused)
|
|
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))
|
|
1023
|
+
const firstAwaiting = threads.find((t) => effectiveStatus(t) === "awaiting")
|
|
994
1024
|
|
|
995
1025
|
const [hubOpen, setHubOpen] = React.useState(true)
|
|
996
1026
|
const [openId, setOpenId] = React.useState<string | null>(() => {
|
|
997
1027
|
if (defaultOpenThreadId) return defaultOpenThreadId
|
|
998
|
-
|
|
999
|
-
return firstActionable ? firstActionable.threadId : null
|
|
1028
|
+
return prioritizedThread ? prioritizedThread.threadId : null
|
|
1000
1029
|
})
|
|
1001
1030
|
|
|
1002
1031
|
if (!threads.length) return null
|
|
@@ -1004,11 +1033,11 @@ function ConversationPanel({
|
|
|
1004
1033
|
// Header badge state: a responded reply leads, then drafts to finish, then sent mail awaiting a reply.
|
|
1005
1034
|
const badge =
|
|
1006
1035
|
responded > 0
|
|
1007
|
-
? { label: "Email response detected", dot: "bg-status-
|
|
1036
|
+
? { label: "Email response detected", dot: "bg-status-warning-fg", ring: "bg-status-warning-fg/30" }
|
|
1008
1037
|
: draft > 0
|
|
1009
1038
|
? { label: "Draft ready", dot: "bg-status-pending-fg", ring: "bg-status-pending-fg/30" }
|
|
1010
1039
|
: awaiting > 0
|
|
1011
|
-
? { label: "
|
|
1040
|
+
? { label: "Email sent · awaiting reply", dot: "bg-status-info-fg", ring: "bg-status-info-fg/30" }
|
|
1012
1041
|
: { label: "Conversations", dot: "bg-muted-foreground/50", ring: "bg-muted-foreground/20" }
|
|
1013
1042
|
|
|
1014
1043
|
const headTitle =
|
|
@@ -1017,30 +1046,56 @@ function ConversationPanel({
|
|
|
1017
1046
|
: draft > 0
|
|
1018
1047
|
? `Draft ready on ${draft} ${draft === 1 ? "thread" : "threads"}`
|
|
1019
1048
|
: awaiting > 0
|
|
1020
|
-
?
|
|
1049
|
+
? awaiting === 1 && firstAwaiting
|
|
1050
|
+
? `Awaiting a response from ${firstName(displayParticipant(firstAwaiting.contact).name)}`
|
|
1051
|
+
: `Awaiting responses on ${awaiting} threads`
|
|
1021
1052
|
: `${threads.length} email ${threads.length === 1 ? "thread" : "threads"}`
|
|
1022
1053
|
|
|
1054
|
+
const panelState = responded > 0 ? "responded" : draft > 0 ? "draft" : awaiting > 0 ? "awaiting" : "viewing"
|
|
1055
|
+
|
|
1023
1056
|
return (
|
|
1024
1057
|
<section
|
|
1025
1058
|
data-slot="conversation-panel"
|
|
1026
1059
|
data-responded={responded > 0 ? "true" : undefined}
|
|
1027
|
-
|
|
1060
|
+
data-state={panelState}
|
|
1061
|
+
className={cn(
|
|
1062
|
+
"bg-background overflow-hidden rounded-xl border",
|
|
1063
|
+
panelState === "responded"
|
|
1064
|
+
? "border-status-warning-border"
|
|
1065
|
+
: panelState === "awaiting"
|
|
1066
|
+
? "border-status-info-border"
|
|
1067
|
+
: "border-border",
|
|
1068
|
+
className,
|
|
1069
|
+
)}
|
|
1028
1070
|
>
|
|
1029
|
-
<
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1071
|
+
<div
|
|
1072
|
+
data-slot="conversation-panel-header"
|
|
1073
|
+
className={cn(
|
|
1074
|
+
"flex w-full items-center gap-2 px-3 py-2.5",
|
|
1075
|
+
panelState === "responded"
|
|
1076
|
+
? "bg-status-warning-bg/45"
|
|
1077
|
+
: panelState === "awaiting"
|
|
1078
|
+
? "bg-status-info-bg/55"
|
|
1079
|
+
: "bg-background",
|
|
1080
|
+
)}
|
|
1034
1081
|
>
|
|
1082
|
+
<button
|
|
1083
|
+
type="button"
|
|
1084
|
+
onClick={() => setHubOpen((v) => !v)}
|
|
1085
|
+
aria-expanded={hubOpen}
|
|
1086
|
+
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
|
1087
|
+
>
|
|
1035
1088
|
<span
|
|
1036
1089
|
data-slot="conversation-badge"
|
|
1037
1090
|
className={cn(
|
|
1038
1091
|
"inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-semibold",
|
|
1039
1092
|
responded > 0
|
|
1040
|
-
? "bg-status-
|
|
1041
|
-
: draft > 0
|
|
1093
|
+
? "bg-status-warning-bg text-status-warning-fg"
|
|
1094
|
+
: draft > 0
|
|
1042
1095
|
? "bg-status-pending-bg text-status-pending-fg"
|
|
1043
|
-
:
|
|
1096
|
+
: awaiting > 0
|
|
1097
|
+
? "bg-status-info-bg text-status-info-fg"
|
|
1098
|
+
: "bg-muted text-muted-foreground"
|
|
1044
1099
|
)}
|
|
1045
1100
|
>
|
|
1046
1101
|
<span className="relative inline-flex size-2">
|
|
@@ -1056,12 +1111,18 @@ function ConversationPanel({
|
|
|
1056
1111
|
{anyPaused ? <> · <b>playbook stopped</b></> : null}
|
|
1057
1112
|
</span>
|
|
1058
1113
|
</span>
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1114
|
+
{hubOpen ? (
|
|
1115
|
+
<ChevronUp size={16} className="text-muted-foreground shrink-0" />
|
|
1116
|
+
) : (
|
|
1117
|
+
<ChevronDown size={16} className="text-muted-foreground shrink-0" />
|
|
1118
|
+
)}
|
|
1119
|
+
</button>
|
|
1120
|
+
{hubGmailThread ? (
|
|
1121
|
+
<div className="shrink-0" onClick={(event) => event.stopPropagation()}>
|
|
1122
|
+
<OpenInGmailButton thread={hubGmailThread} onOpenInGmail={onOpenInGmail} />
|
|
1123
|
+
</div>
|
|
1124
|
+
) : null}
|
|
1125
|
+
</div>
|
|
1065
1126
|
|
|
1066
1127
|
{hubOpen ? (
|
|
1067
1128
|
<div className="border-border border-t">
|
|
@@ -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
|
}
|