@actioneer/ads 1.0.8 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/DESIGN.md +125 -50
  2. package/README.md +31 -7
  3. package/components/answer.js +1 -1
  4. package/components/assistant-ui/composer-harness.js +14 -10
  5. package/components/body-page-header.d.ts +9 -4
  6. package/components/body-page-header.js +10 -10
  7. package/components/breadcrumbs.js +15 -15
  8. package/components/button.d.ts +16 -1
  9. package/components/button.js +7 -4
  10. package/components/card.js +18 -18
  11. package/components/chart.d.ts +40 -4
  12. package/components/chart.js +824 -628
  13. package/components/chip.d.ts +2 -1
  14. package/components/chip.js +49 -46
  15. package/components/collection-header.js +4 -4
  16. package/components/connector-card.js +30 -30
  17. package/components/date-range-picker.js +1 -1
  18. package/components/detail-list.d.ts +28 -0
  19. package/components/detail-list.js +52 -0
  20. package/components/drawer.d.ts +38 -15
  21. package/components/drawer.js +150 -86
  22. package/components/empty-state.d.ts +3 -2
  23. package/components/empty-state.js +22 -22
  24. package/components/field.d.ts +16 -0
  25. package/components/field.js +63 -42
  26. package/components/file-dropzone.d.ts +15 -4
  27. package/components/file-dropzone.js +100 -64
  28. package/components/filter-bar.d.ts +44 -0
  29. package/components/filter-bar.js +89 -0
  30. package/components/filters.d.ts +37 -0
  31. package/components/filters.js +129 -0
  32. package/components/index.d.ts +4 -0
  33. package/components/index.js +526 -510
  34. package/components/input-field.d.ts +1 -1
  35. package/components/input-field.js +24 -22
  36. package/components/modal.d.ts +2 -1
  37. package/components/modal.js +120 -106
  38. package/components/panel.js +26 -27
  39. package/components/popover.d.ts +6 -1
  40. package/components/popover.js +74 -64
  41. package/components/sidebar.d.ts +15 -0
  42. package/components/sidebar.js +166 -129
  43. package/components/sparkline.d.ts +30 -0
  44. package/components/sparkline.js +69 -0
  45. package/components/table.d.ts +8 -0
  46. package/components/table.js +357 -350
  47. package/components/tabs.d.ts +6 -1
  48. package/components/tabs.js +28 -31
  49. package/demo/components/add-to-artifact.js +6 -2
  50. package/examples/README.md +5 -2
  51. package/examples/call-logs-screen.tsx +381 -0
  52. package/examples/campaign-analysis-screen.tsx +180 -0
  53. package/examples/campaigns-screen.tsx +497 -0
  54. package/examples/guardrails-settings-screen.tsx +123 -0
  55. package/fonts/geist-variable.woff2 +0 -0
  56. package/fonts.css +12 -17
  57. package/lib/icons.js +3 -3
  58. package/llms.txt +162 -30
  59. package/manifest.json +11604 -7878
  60. package/package.json +1 -1
  61. package/styles.css +102 -59
  62. package/theme.css +81 -37
  63. package/fonts/aribau-grotesk-medium.woff2 +0 -0
  64. package/fonts/aribau-grotesk-regular.woff2 +0 -0
@@ -19,7 +19,12 @@ export declare const TabList: React.ForwardRefExoticComponent<TabListProps & Rea
19
19
  export interface TabProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
20
20
  value: string;
21
21
  isDisabled?: boolean;
22
- /** Optional trailing count, rendered as `· N` (e.g. a filter's match count). */
22
+ /**
23
+ * Optional trailing count (e.g. a filter's match count), rendered as the bare
24
+ * number 6px after the label. It stays quieter than the label: Regular
25
+ * weight and tabular figures in every state, tertiary grey when inactive and
26
+ * the primary-action teal when active.
27
+ */
23
28
  count?: number;
24
29
  }
25
30
  export declare const Tab: React.ForwardRefExoticComponent<TabProps & React.RefAttributes<HTMLButtonElement>>;
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { jsx as m, jsxs as E } from "react/jsx-runtime";
2
+ import { jsx as p, jsxs as E } from "react/jsx-runtime";
3
3
  import * as r from "react";
4
4
  import { MotionConfig as j, motion as $ } from "motion/react";
5
5
  import { cva as L } from "class-variance-authority";
@@ -18,26 +18,26 @@ const U = r.forwardRef(
18
18
  value: e,
19
19
  defaultValue: t,
20
20
  onValueChange: a,
21
- variant: w,
21
+ variant: g,
22
22
  orientation: c = "horizontal",
23
- size: p,
23
+ size: m,
24
24
  children: R,
25
25
  ...A
26
26
  }, k) => {
27
- const x = e !== void 0, [y, v] = r.useState(t), T = r.useId().replaceAll(":", ""), d = r.useId(), g = r.useRef([]), h = r.useRef(/* @__PURE__ */ new Set()), f = x ? e : y, I = r.useCallback(
27
+ const x = e !== void 0, [y, v] = r.useState(t), T = r.useId().replaceAll(":", ""), d = r.useId(), w = r.useRef([]), h = r.useRef(/* @__PURE__ */ new Set()), f = x ? e : y, I = r.useCallback(
28
28
  (l) => {
29
29
  x || v(l), a == null || a(l);
30
30
  },
31
31
  [x, a]
32
32
  ), i = r.useCallback((l) => {
33
- g.current.includes(l) || g.current.push(l);
33
+ w.current.includes(l) || w.current.push(l);
34
34
  }, []), s = r.useMemo(
35
35
  () => ({
36
36
  value: f,
37
37
  setValue: I,
38
38
  baseId: T,
39
39
  registerTab: i,
40
- tabs: g,
40
+ tabs: w,
41
41
  disabled: h,
42
42
  thumbId: d,
43
43
  orientation: c
@@ -51,7 +51,7 @@ const U = r.forwardRef(
51
51
  c
52
52
  ]
53
53
  );
54
- return /* @__PURE__ */ m(P.Provider, { value: s, children: /* @__PURE__ */ m(
54
+ return /* @__PURE__ */ p(P.Provider, { value: s, children: /* @__PURE__ */ p(
55
55
  "div",
56
56
  {
57
57
  ref: k,
@@ -63,13 +63,13 @@ const U = r.forwardRef(
63
63
  o
64
64
  ),
65
65
  ...A,
66
- children: /* @__PURE__ */ m(j, { reducedMotion: "user", children: R })
66
+ children: /* @__PURE__ */ p(j, { reducedMotion: "user", children: R })
67
67
  }
68
68
  ) });
69
69
  }
70
70
  );
71
71
  U.displayName = "Tabs";
72
- const C = L("inline-flex text-l", {
72
+ const C = L("inline-flex text-m", {
73
73
  variants: {
74
74
  orientation: {
75
75
  horizontal: b(
@@ -88,7 +88,7 @@ const C = L("inline-flex text-l", {
88
88
  }), B = r.forwardRef(
89
89
  ({ className: o, ...e }, t) => {
90
90
  const { orientation: a } = z("TabList");
91
- return /* @__PURE__ */ m(
91
+ return /* @__PURE__ */ p(
92
92
  "div",
93
93
  {
94
94
  ref: t,
@@ -127,7 +127,7 @@ const H = L(
127
127
  }
128
128
  }
129
129
  ), O = r.forwardRef(
130
- ({ className: o, value: e, isDisabled: t, count: a, children: w, onClick: c, onKeyDown: p, ...R }, A) => {
130
+ ({ className: o, value: e, isDisabled: t, count: a, children: g, onClick: c, onKeyDown: m, ...R }, A) => {
131
131
  const {
132
132
  value: k,
133
133
  setValue: x,
@@ -135,7 +135,7 @@ const H = L(
135
135
  registerTab: v,
136
136
  tabs: T,
137
137
  disabled: d,
138
- thumbId: g,
138
+ thumbId: w,
139
139
  orientation: h
140
140
  } = z("Tab"), f = k === e;
141
141
  r.useEffect(() => {
@@ -144,7 +144,7 @@ const H = L(
144
144
  t ? d.current.add(e) : d.current.delete(e);
145
145
  }, [d, t, e]);
146
146
  const I = (i) => {
147
- if (p == null || p(i), i.defaultPrevented) return;
147
+ if (m == null || m(i), i.defaultPrevented) return;
148
148
  const s = T.current, l = s.indexOf(e);
149
149
  if (l === -1) return;
150
150
  const V = h === "vertical" ? ["ArrowDown"] : ["ArrowRight", "ArrowDown"], _ = h === "vertical" ? ["ArrowUp"] : ["ArrowLeft", "ArrowUp"];
@@ -199,10 +199,10 @@ const H = L(
199
199
  onKeyDown: I,
200
200
  ...R,
201
201
  children: [
202
- f && /* @__PURE__ */ m(
202
+ f && /* @__PURE__ */ p(
203
203
  $.span,
204
204
  {
205
- layoutId: g,
205
+ layoutId: w,
206
206
  "aria-hidden": !0,
207
207
  transition: M,
208
208
  className: b(
@@ -211,19 +211,16 @@ const H = L(
211
211
  )
212
212
  }
213
213
  ),
214
- /* @__PURE__ */ E("span", { className: "relative inline-flex items-center gap-1", children: [
215
- w,
216
- a != null && /* @__PURE__ */ E(
214
+ /* @__PURE__ */ E("span", { className: "relative inline-flex items-center gap-1.5", children: [
215
+ g,
216
+ a != null && /* @__PURE__ */ p(
217
217
  "span",
218
218
  {
219
219
  className: b(
220
- "tabular-nums",
221
- f ? "text-action-primary-text" : "text-secondary"
220
+ "font-normal tabular-nums",
221
+ f ? "text-action-primary-text" : "text-on-white-tertiary"
222
222
  ),
223
- children: [
224
- "· ",
225
- a
226
- ]
223
+ children: a
227
224
  }
228
225
  )
229
226
  ] })
@@ -236,7 +233,7 @@ O.displayName = "Tab";
236
233
  const q = r.forwardRef(
237
234
  ({ className: o, ...e }, t) => {
238
235
  const { orientation: a } = z("TabSeparator");
239
- return /* @__PURE__ */ m(
236
+ return /* @__PURE__ */ p(
240
237
  "span",
241
238
  {
242
239
  ref: t,
@@ -254,15 +251,15 @@ const q = r.forwardRef(
254
251
  );
255
252
  q.displayName = "TabSeparator";
256
253
  const F = r.forwardRef(
257
- ({ className: o, value: e, children: t, ...a }, w) => {
258
- const { value: c, baseId: p } = z("TabPanel");
259
- return c !== e ? null : /* @__PURE__ */ m(
254
+ ({ className: o, value: e, children: t, ...a }, g) => {
255
+ const { value: c, baseId: m } = z("TabPanel");
256
+ return c !== e ? null : /* @__PURE__ */ p(
260
257
  "div",
261
258
  {
262
- ref: w,
259
+ ref: g,
263
260
  role: "tabpanel",
264
- id: N(p, "panel", e),
265
- "aria-labelledby": N(p, "tab", e),
261
+ id: N(m, "panel", e),
262
+ "aria-labelledby": N(m, "tab", e),
266
263
  tabIndex: 0,
267
264
  className: b(
268
265
  "flex-1 outline-none focus-visible:ring-3 focus-visible:ring-ring rounded-sm",
@@ -35,6 +35,8 @@ import "react-aria-components";
35
35
  import "../../components/autocomplete.js";
36
36
  import "../../components/slider.js";
37
37
  import "../../components/segmented.js";
38
+ import "../../components/filters.js";
39
+ import "../../components/filter-bar.js";
38
40
  import { Modal as q, ModalHeader as J, ModalTitle as Q, ModalBody as x, ModalFooter as g } from "../../components/modal.js";
39
41
  import "../../components/drawer.js";
40
42
  import "../../components/popover.js";
@@ -55,6 +57,8 @@ import "../../components/avatar.js";
55
57
  import "../../components/badge.js";
56
58
  import "../../components/chip.js";
57
59
  import "../../components/timeline.js";
60
+ import "../../components/detail-list.js";
61
+ import "../../components/sparkline.js";
58
62
  import "../../components/alert.js";
59
63
  import "../../components/surface-loader.js";
60
64
  import "../../components/pixel-blast-svg.js";
@@ -106,7 +110,7 @@ import "../../components/assistant-ui/quote.js";
106
110
  import "../../components/assistant-ui/sources.js";
107
111
  import "../../components/assistant-ui/tool-fallback.js";
108
112
  import "../../components/assistant-ui/tool-group.js";
109
- function ei({
113
+ function ni({
110
114
  block: i,
111
115
  trigger: v = "button",
112
116
  className: A,
@@ -358,5 +362,5 @@ function ei({
358
362
  ] });
359
363
  }
360
364
  export {
361
- ei as AddToArtifact
365
+ ni as AddToArtifact
362
366
  };
@@ -13,6 +13,10 @@ root in `MotionConfig`, `ToastProvider` and `TooltipProvider`.
13
13
  | `agents-screen.tsx` | `AgentsScreen` | A full list page: `PageHeader` with a primary action, a summary strip, `Tabs` with counts leading a `SearchDisclosure`, a filter `Dropdown`, a sortable `Table` with clickable rows and a pinned action column, per-row actions with `Tooltip`s, and filtered `EmptyState`s. |
14
14
  | `settings-screen.tsx` | `SettingsScreen` | A settings card: `Card` header with a status `Badge`, an `InputField`, a `SettingList` of `SettingRow`s with `Switch` and `Button` controls, and one primary action. |
15
15
  | `workspace-header.tsx` | `WorkspaceHeaderScreen` | A page header with a status `Badge` and trailing actions, then `Tabs` with counts whose panels hold `Card`s. |
16
+ | `campaigns-screen.tsx` | `CampaignsScreen` | The collection-screen recipe: `BodyPageHeader` with one primary action, counted `Tabs` leading `SearchDisclosure` + `Filters`, a multi-select `Table` (hover-revealed checkboxes, cohort child rows, `isActive` open row) whose page size fills the panel with `Pagination` at the bottom, a shell-level `Drawer` with status under the title and Performance / Details sections, and the fixed-height New campaign `Modal` (`NewCampaignModal`: `Field info` for optional guidance, `Field actions` for the source switch, inline `FileDropzone`, single-select `Table` of saved audiences). |
17
+ | `call-logs-screen.tsx` | `CallLogsScreen`, `CallPanel` | The record-call-logs recipe: a campaign's Call logs tab titled by `Breadcrumbs` (status in `meta`, Test call + Launch in `actions`), a `Table` of calls (Number · plain-chip Status · Outcome · Duration) that fills the panel, and a footer-less call panel — grounded `DrawerHeader`, scrolling `DrawerSection`s (Summary, Recording, Transcript, Follow-ups, CRM write-back, Captured fields) with `DetailList` attributes and a collapsed Call metadata. |
18
+ | `guardrails-settings-screen.tsx` | `GuardrailsSettingsScreen` | The settings-screen recipe: `BodyPageHeader` with one save action and full-width setting groups (16px title over a `SettingList` of `SettingRow`s with Switch / value Button / Badge controls). |
19
+ | `campaign-analysis-screen.tsx` | `CampaignAnalysisScreen` | The record-analytics recipe: an 18px `Breadcrumbs` title with the primary action on the same row, record `Tabs`, outlined stat cards with `Progress`, and outlined `BarChart` / `LineChart` cards with header legends and printed values. |
16
20
 
17
21
  ```tsx
18
22
  // app/agents/page.tsx
@@ -25,6 +29,5 @@ export default function Page() {
25
29
  }
26
30
  ```
27
31
 
28
- These are the same compositions the ADS Storybook renders under
29
- **Recipes**, so what you see there is what these files produce. Icons come from
32
+ Icons come from
30
33
  `@actioneer/ads/lib/icons`; never install a second icon package.
@@ -0,0 +1,381 @@
1
+ /**
2
+ * Call logs screen — the ADS record-call-logs recipe, built only from ADS
3
+ * exports.
4
+ *
5
+ * Follows principles `record-detail-panels`, `collection-screens`,
6
+ * `leading-back-navigation` and `status-language` (see `get_principles`):
7
+ *
8
+ * - Call logs is a TAB of its campaign, not a page of its own: the 16px
9
+ * Breadcrumbs trail (Voice campaigns / campaign) is the title, the campaign
10
+ * status Chip sits beside it, and the campaign actions (Test call, Launch)
11
+ * sit on the right of the same row — a 64px header bar closed by a
12
+ * full-width hairline.
13
+ * - The list is a Table — Number · Status · Outcome · Duration — whose rows
14
+ * fill the panel with Pagination at the bottom. Status is a plain Chip
15
+ * (square dot + 14px text: Completed green, No answer amber, Failed red);
16
+ * the outcome exists only once a call connected, otherwise an em dash.
17
+ * - Opening a call marks its row `isActive` and mounts the call panel beside
18
+ * the list, attached to the page by one full-height hairline.
19
+ * - The call panel: DrawerHeader (kicker, number, status + time · length),
20
+ * then DrawerSections 32px apart — Summary, Recording, Transcript · N turns,
21
+ * Follow-ups, CRM write-back, Captured fields · X of N — and Call metadata
22
+ * collapsed last. Each fact appears once. No footer: there is no dedicated
23
+ * call page, so there is nothing to "open".
24
+ *
25
+ * Copy it and replace CALLS with your own records.
26
+ */
27
+ import * as React from "react";
28
+ import {
29
+ AudioWaveform,
30
+ BreadcrumbItem,
31
+ Breadcrumbs,
32
+ Button,
33
+ Chip,
34
+ Collapsible,
35
+ CollapsibleContent,
36
+ CollapsibleTrigger,
37
+ DetailItem,
38
+ DetailList,
39
+ Drawer,
40
+ DrawerBody,
41
+ DrawerHeader,
42
+ DrawerKicker,
43
+ DrawerMeta,
44
+ DrawerSection,
45
+ DrawerTitle,
46
+ Filters,
47
+ Pagination,
48
+ SearchDisclosure,
49
+ Tab,
50
+ TabList,
51
+ Table,
52
+ TableBody,
53
+ TableCell,
54
+ TableColumn,
55
+ TableHeader,
56
+ TableRow,
57
+ Tabs,
58
+ Transcript,
59
+ type FilterGroup,
60
+ type TranscriptTurn,
61
+ } from "@actioneer/ads";
62
+ import { ChevronDown } from "@actioneer/ads/lib/icons";
63
+
64
+ /** Call statuses, one tone per meaning (principle `status-language`). */
65
+ export type CallStatus = "Completed" | "No answer" | "Busy" | "Failed";
66
+
67
+ export const CALL_STATUS_COLOR: Record<CallStatus, "success" | "warning" | "danger"> = {
68
+ Completed: "success",
69
+ "No answer": "warning",
70
+ Busy: "warning",
71
+ Failed: "danger",
72
+ };
73
+
74
+ export interface CallRecord {
75
+ id: string;
76
+ number: string;
77
+ status: CallStatus;
78
+ /** Disposition — only for calls that connected. */
79
+ outcome?: string;
80
+ seconds: number;
81
+ startedAt: string;
82
+ summary?: string;
83
+ cost: string;
84
+ pickedUp: boolean;
85
+ transcript: TranscriptTurn[];
86
+ followUps: string[];
87
+ crm: { nextAction?: string; reason?: string };
88
+ /** Field key → captured value (undefined = not stated). */
89
+ captured: Record<string, string | undefined>;
90
+ metadata: { callId: string; agent: string; callerId: string; provider: string };
91
+ }
92
+
93
+ /** Sentence-case labels for captured field keys — never show the raw key. */
94
+ export const CAPTURED_FIELD_LABELS: Record<string, string> = {
95
+ customer_name: "Customer name",
96
+ type_of_loan: "Type of loan",
97
+ loan_amount: "Loan amount",
98
+ city: "City",
99
+ pincode: "Pincode",
100
+ type_of_employment: "Type of employment",
101
+ approximate_monthly_income: "Monthly income",
102
+ property_status: "Property status",
103
+ property_amount: "Property amount",
104
+ callback_slot: "Callback slot",
105
+ };
106
+
107
+ const EMPTY_CAPTURE = Object.fromEntries(Object.keys(CAPTURED_FIELD_LABELS).map((key) => [key, undefined]));
108
+
109
+ const call = (number: string, status: CallStatus, seconds: number, extra: Partial<CallRecord> = {}): CallRecord => ({
110
+ id: number.replace(/\D/g, ""),
111
+ number,
112
+ status,
113
+ seconds,
114
+ startedAt: "12 Sep, 3:41 PM",
115
+ cost: "₹0.00",
116
+ pickedUp: status === "Completed",
117
+ transcript: [],
118
+ followUps: [],
119
+ crm: {},
120
+ captured: EMPTY_CAPTURE,
121
+ metadata: { callId: "d8138618-0042", agent: "Asha · Hindi", callerId: "+91 22 6987 0900", provider: "Plivo" },
122
+ ...extra,
123
+ });
124
+
125
+ export const CALLS: CallRecord[] = [
126
+ call("+91 98393 81298", "No answer", 0),
127
+ call("+91 87222 96094", "No answer", 0),
128
+ call("+91 98857 55120", "No answer", 0),
129
+ call("+91 90053 11157", "No answer", 0),
130
+ call("+91 86686 86494", "Completed", 4, {
131
+ outcome: "Customer did not speak",
132
+ summary: "An unknown customer answered but did not respond. No next step was established.",
133
+ transcript: [{ id: "t1", role: "agent", timeLabel: "0:01", text: "Hi, this is Asha calling from Liquide about your loan enquiry. Is this a good time to talk?" }],
134
+ crm: { reason: "The customer did not provide any voice response." },
135
+ }),
136
+ call("+91 73853 99695", "No answer", 0),
137
+ call("+91 94880 36418", "Completed", 10, { outcome: "Customer did not speak", summary: "The customer picked up and hung up after the greeting." }),
138
+ call("+91 74477 96759", "No answer", 0),
139
+ call("+91 75682 95332", "Completed", 7, { outcome: "Customer did not speak", summary: "The customer picked up and stayed silent." }),
140
+ call("+91 78876 09135", "Busy", 0),
141
+ ];
142
+
143
+ const TABS = ["Script", "Workflow", "Analysis", "Call logs", "Reports", "Guardrails"];
144
+
145
+ const INITIAL_FILTERS: FilterGroup[] = [
146
+ {
147
+ id: "status",
148
+ label: "Status",
149
+ value: "all",
150
+ options: [
151
+ { value: "all", label: "All statuses" },
152
+ { value: "Completed", label: "Completed" },
153
+ { value: "No answer", label: "No answer" },
154
+ { value: "Busy", label: "Busy" },
155
+ { value: "Failed", label: "Failed" },
156
+ ],
157
+ },
158
+ ];
159
+
160
+ const ROW_HEIGHT = 64;
161
+ const TABLE_CHROME = 90;
162
+
163
+ function useRowsThatFit(ref: React.RefObject<HTMLDivElement | null>) {
164
+ const [rows, setRows] = React.useState(8);
165
+ React.useLayoutEffect(() => {
166
+ const node = ref.current;
167
+ if (!node) return;
168
+ const update = () => setRows(Math.max(3, Math.floor((node.clientHeight - TABLE_CHROME) / ROW_HEIGHT)));
169
+ update();
170
+ const observer = new ResizeObserver(update);
171
+ observer.observe(node);
172
+ return () => observer.disconnect();
173
+ }, [ref]);
174
+ return rows;
175
+ }
176
+
177
+ const seconds = (value: number) => `${value}s`;
178
+
179
+ /** The call panel: header owns status, time and length; the body never repeats them. */
180
+ export function CallPanel({ call, onClose }: { call: CallRecord | undefined; onClose: () => void }) {
181
+ const capturedKeys = Object.keys(CAPTURED_FIELD_LABELS);
182
+ const filled = call ? capturedKeys.filter((key) => call.captured[key]).length : 0;
183
+
184
+ return (
185
+ <Drawer open={Boolean(call)} onOpenChange={(open) => !open && onClose()} aria-label="Call details">
186
+ {call && (
187
+ <>
188
+ <DrawerHeader>
189
+ <DrawerKicker>Call</DrawerKicker>
190
+ <DrawerTitle className="tabular-nums">{call.number}</DrawerTitle>
191
+ <DrawerMeta>
192
+ <Chip color={CALL_STATUS_COLOR[call.status]}>{call.status}</Chip>
193
+ <span>
194
+ {call.startedAt} · {seconds(call.seconds)}
195
+ </span>
196
+ </DrawerMeta>
197
+ </DrawerHeader>
198
+ <DrawerBody className="flex flex-col gap-8">
199
+ <DrawerSection title="Summary">
200
+ {call.outcome && (
201
+ <div className="flex">
202
+ <Chip variant="outline">{call.outcome}</Chip>
203
+ </div>
204
+ )}
205
+ <p className="m-0 text-m text-on-white-primary">{call.summary ?? "The call did not connect."}</p>
206
+ <dl className="m-0 mt-1 grid grid-cols-2 gap-2">
207
+ {[
208
+ { label: "Cost", value: call.cost },
209
+ { label: "Picked up", value: call.pickedUp ? "Yes" : "No" },
210
+ ].map((stat) => (
211
+ <div key={stat.label} className="min-w-0 rounded-card border border-border bg-wg1 p-3">
212
+ <dt className="text-s text-on-white-secondary">{stat.label}</dt>
213
+ <dd className="m-0 mt-0.5 text-xl tabular-nums text-on-white-primary">{stat.value}</dd>
214
+ </div>
215
+ ))}
216
+ </dl>
217
+ </DrawerSection>
218
+
219
+ {call.pickedUp && (
220
+ <DrawerSection title="Recording">
221
+ <AudioWaveform src="/samples/conversation-24s.wav" durationSeconds={call.seconds} channels={2} downloadName={`call-${call.id}.wav`} />
222
+ </DrawerSection>
223
+ )}
224
+
225
+ <DrawerSection title="Transcript" count={`${call.transcript.length} ${call.transcript.length === 1 ? "turn" : "turns"}`}>
226
+ <Transcript turns={call.transcript} emptyMessage="No one spoke on this call." />
227
+ </DrawerSection>
228
+
229
+ <DrawerSection title="Follow-ups">
230
+ {call.followUps.length ? (
231
+ <ul className="m-0 list-none p-0 text-m text-on-white-primary">
232
+ {call.followUps.map((item) => <li key={item}>{item}</li>)}
233
+ </ul>
234
+ ) : (
235
+ <p className="m-0 text-m text-on-white-secondary">No follow-up recorded</p>
236
+ )}
237
+ </DrawerSection>
238
+
239
+ <DrawerSection title="CRM write-back">
240
+ <DetailList>
241
+ <DetailItem label="Next action" emptyLabel="None">{call.crm.nextAction}</DetailItem>
242
+ <DetailItem label="Reason">{call.crm.reason}</DetailItem>
243
+ </DetailList>
244
+ </DrawerSection>
245
+
246
+ <DrawerSection title="Captured fields" count={`${filled} of ${capturedKeys.length}`}>
247
+ <DetailList>
248
+ {capturedKeys.map((key) => (
249
+ <DetailItem key={key} label={CAPTURED_FIELD_LABELS[key]}>{call.captured[key]}</DetailItem>
250
+ ))}
251
+ </DetailList>
252
+ </DrawerSection>
253
+
254
+ {/* Rarely needed, so it starts collapsed and closes the panel. */}
255
+ <Collapsible>
256
+ <CollapsibleTrigger className="group flex min-h-10 w-full items-center justify-between border-t border-tertiary font-medium text-on-white-primary">
257
+ Call metadata
258
+ <ChevronDown aria-hidden className="size-4 text-on-white-secondary transition-transform duration-150 group-data-[state=open]:rotate-180 motion-reduce:transition-none" />
259
+ </CollapsibleTrigger>
260
+ <CollapsibleContent>
261
+ <DetailList>
262
+ <DetailItem label="Call ID">{call.metadata.callId}</DetailItem>
263
+ <DetailItem label="Agent">{call.metadata.agent}</DetailItem>
264
+ <DetailItem label="Caller ID">{call.metadata.callerId}</DetailItem>
265
+ <DetailItem label="Provider">{call.metadata.provider}</DetailItem>
266
+ </DetailList>
267
+ </CollapsibleContent>
268
+ </Collapsible>
269
+ </DrawerBody>
270
+ </>
271
+ )}
272
+ </Drawer>
273
+ );
274
+ }
275
+
276
+ export function CallLogsScreen() {
277
+ const [tab, setTab] = React.useState("Call logs");
278
+ const [search, setSearch] = React.useState("");
279
+ const [searchOpen, setSearchOpen] = React.useState(false);
280
+ const [filters, setFilters] = React.useState(INITIAL_FILTERS);
281
+ const [openId, setOpenId] = React.useState<string | null>(null);
282
+ const [page, setPage] = React.useState(1);
283
+ const rowsRef = React.useRef<HTMLDivElement>(null);
284
+ const pageSize = useRowsThatFit(rowsRef);
285
+
286
+ const status = filters.find((group) => group.id === "status")?.value ?? "all";
287
+ const query = search.replace(/\s/g, "");
288
+ const visible = CALLS.filter((c) => (status === "all" || c.status === status) && (!query || c.number.replace(/\s/g, "").includes(query)));
289
+ const pages = Math.max(1, Math.ceil(visible.length / pageSize));
290
+ const current = Math.min(page, pages);
291
+ const pageRows = visible.slice((current - 1) * pageSize, current * pageSize);
292
+
293
+ return (
294
+ <div className="flex h-screen bg-surface p-3 text-foreground">
295
+ {/* The app window: one rounded surface on the WG3 backdrop. */}
296
+ <div className="flex min-w-0 flex-1 overflow-hidden rounded-window border border-border bg-card shadow-window">
297
+ <section aria-label="12-09-2026" className="flex min-w-0 flex-1 flex-col overflow-hidden">
298
+ {/* The breadcrumb is the title; status sits beside it, campaign actions on the right. */}
299
+ <div className="flex h-16 shrink-0 items-center border-b border-border px-6">
300
+ <Breadcrumbs className="flex-1"
301
+ meta={<Chip color="success">Completed</Chip>}
302
+ actions={
303
+ <>
304
+ <Button variant="outline">Test call</Button>
305
+ <Button>Launch</Button>
306
+ </>
307
+ }
308
+ >
309
+ <BreadcrumbItem href="#campaigns">Voice campaigns</BreadcrumbItem>
310
+ <BreadcrumbItem isCurrent>12-09-2026</BreadcrumbItem>
311
+ </Breadcrumbs>
312
+ </div>
313
+
314
+ <div className="flex items-center justify-between gap-4 px-6 pt-5">
315
+ <Tabs className="min-w-0 flex-1" value={tab} onValueChange={setTab}>
316
+ <TabList aria-label="Campaign sections">
317
+ {TABS.map((item) => (
318
+ <Tab key={item} value={item} count={item === "Call logs" ? CALLS.length : undefined}>
319
+ {item}
320
+ </Tab>
321
+ ))}
322
+ </TabList>
323
+ </Tabs>
324
+ <div className="flex shrink-0 items-center gap-2">
325
+ <SearchDisclosure value={search} onValueChange={(value) => { setSearch(value); setPage(1); }} open={searchOpen} onOpenChange={setSearchOpen} placeholder="Search numbers" triggerLabel="Search calls" />
326
+ <Filters
327
+ triggerLabel="Filter calls"
328
+ groups={filters}
329
+ onChange={(id, value) => { setFilters((groups) => groups.map((group) => (group.id === id ? { ...group, value } : group))); setPage(1); }}
330
+ />
331
+ </div>
332
+ </div>
333
+
334
+ <div ref={rowsRef} className="min-h-0 flex-1 px-6 pt-4 pb-6">
335
+ <Table
336
+ aria-label="Call logs"
337
+ footer={
338
+ <Pagination
339
+ className="w-full"
340
+ total={pages}
341
+ page={current}
342
+ onChange={setPage}
343
+ summary={`${visible.length === 0 ? 0 : (current - 1) * pageSize + 1}–${Math.min(current * pageSize, visible.length)} of ${visible.length} calls`}
344
+ />
345
+ }
346
+ >
347
+ <TableHeader>
348
+ <TableRow>
349
+ <TableColumn>Number</TableColumn>
350
+ <TableColumn>Status</TableColumn>
351
+ <TableColumn>Outcome</TableColumn>
352
+ <TableColumn align="right">Duration</TableColumn>
353
+ </TableRow>
354
+ </TableHeader>
355
+ <TableBody>
356
+ {pageRows.map((row) => (
357
+ <TableRow
358
+ key={row.id}
359
+ rowKey={row.id}
360
+ aria-label={`Open call with ${row.number}`}
361
+ isActive={openId === row.id}
362
+ onClick={() => setOpenId((id) => (id === row.id ? null : row.id))}
363
+ >
364
+ <TableCell className="tabular-nums">{row.number}</TableCell>
365
+ <TableCell>
366
+ <Chip variant="plain" color={CALL_STATUS_COLOR[row.status]}>{row.status}</Chip>
367
+ </TableCell>
368
+ <TableCell className={row.outcome ? undefined : "text-on-white-secondary"}>{row.outcome ?? "—"}</TableCell>
369
+ <TableCell align="right" className="tabular-nums">{seconds(row.seconds)}</TableCell>
370
+ </TableRow>
371
+ ))}
372
+ </TableBody>
373
+ </Table>
374
+ </div>
375
+ </section>
376
+
377
+ <CallPanel call={CALLS.find((c) => c.id === openId)} onClose={() => setOpenId(null)} />
378
+ </div>
379
+ </div>
380
+ );
381
+ }