@startsimpli/integrations 0.1.4 → 0.1.5

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.
@@ -0,0 +1,612 @@
1
+ /**
2
+ * The Library — the corpus surface, as ONE composer (bd startsim-768w.38.6).
3
+ *
4
+ * `<LibraryPage api={corpus} scope={scope} />` is the whole integration. An app
5
+ * owns theme classNames and a wrapper of ten lines, the same shape as
6
+ * `ConnectionsPage` and for the same reason: shipping primitives and letting
7
+ * each app assemble the page just relocates the duplication one level up
8
+ * (feedback_share_page_composers_not_just_primitives).
9
+ *
10
+ * WHAT IT RENDERS, in the order the question is actually asked:
11
+ *
12
+ * 1. FRESHNESS per source — when did each one last pull, and did it work. This
13
+ * is on the screen, not in a settings page, because a corpus silently four
14
+ * days stale is what makes an AI answer confidently wrong.
15
+ * 2. FILTERS — search, source, party, date window. Every one of them is a
16
+ * SERVER parameter (CLAUDE.md rule 8); nothing is filtered in the browser.
17
+ * 3. The LIST, every row wearing its source as a visible badge.
18
+ * 4. The DETAIL, with participants as Party links and whatever long-form the
19
+ * source attached, under the source's own name for it.
20
+ *
21
+ * NOTHING HERE NAMES A VENDOR. The standing test for every element below is
22
+ * "would this row, badge, filter or empty state still make sense if the only
23
+ * connected source were Dropbox?" — so the source list comes from the freshness
24
+ * data, the badge renders whatever label the catalog gave it, and long-form text
25
+ * is a labelled `section` rather than a transcript. A screen that only works for
26
+ * meeting notes is a failed design even though it would demo fine.
27
+ */
28
+ import * as React from 'react';
29
+
30
+ import { enforceScope } from '../../corpus/retrieval';
31
+ import type {
32
+ CorpusApi,
33
+ CorpusScope,
34
+ DocumentQuery,
35
+ Party,
36
+ SourceDocument,
37
+ SourceFreshness,
38
+ } from '../../corpus/types';
39
+
40
+ export interface LibraryPageProps {
41
+ api: CorpusApi;
42
+ /** The asking principal's tenant scope. Rows outside it are never rendered. */
43
+ scope: CorpusScope;
44
+ /** Open straight onto one record — how a citation from the Ask surface lands. */
45
+ initialDocumentId?: string;
46
+ /** Told whenever the open record changes, so an app can put it in the URL. */
47
+ onSelectDocument?: (documentId: string | null) => void;
48
+ pageSize?: number;
49
+ classNames?: Partial<Record<LibrarySlot, string>>;
50
+ }
51
+
52
+ type LibrarySlot =
53
+ | 'root'
54
+ | 'section'
55
+ | 'heading'
56
+ | 'card'
57
+ | 'row'
58
+ | 'badge'
59
+ | 'button'
60
+ | 'input'
61
+ | 'muted'
62
+ | 'error'
63
+ | 'warning';
64
+
65
+ const DEFAULTS: Record<LibrarySlot, string> = {
66
+ root: 'space-y-6',
67
+ section: 'space-y-3',
68
+ heading: 'text-lg font-semibold text-gray-900',
69
+ card: 'rounded-lg border border-gray-200 p-4',
70
+ row: 'w-full rounded-lg border border-gray-200 p-4 text-left hover:border-gray-400',
71
+ badge: 'inline-flex items-center rounded-full border border-gray-300 px-2 py-0.5 text-xs text-gray-700',
72
+ button: 'rounded-md bg-gray-900 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50',
73
+ input: 'rounded-md border border-gray-300 px-3 py-1.5 text-sm',
74
+ muted: 'text-sm text-gray-500',
75
+ error: 'text-sm text-red-600',
76
+ warning: 'rounded-md border border-amber-300 bg-amber-50 p-2 text-sm text-amber-800',
77
+ };
78
+
79
+ /** A refusal must read as a refusal, not as an empty corpus. */
80
+ function readFailureMessage(err: unknown): string {
81
+ const status = (err as { status?: number })?.status;
82
+ if (status === 403) return 'You do not have access to this workspace’s records.';
83
+ if (status === 401) return 'Your session has expired. Sign in again.';
84
+ return (err as { message?: string })?.message || 'Could not load the library.';
85
+ }
86
+
87
+ function formatDate(value: string | null | undefined): string {
88
+ if (!value) return 'No date';
89
+ const at = Date.parse(value);
90
+ if (Number.isNaN(at)) return 'No date';
91
+ return new Date(at).toLocaleDateString(undefined, {
92
+ year: 'numeric',
93
+ month: 'short',
94
+ day: 'numeric',
95
+ });
96
+ }
97
+
98
+ function relative(value: string | null | undefined, now: number): string {
99
+ if (!value) return 'never';
100
+ const at = Date.parse(value);
101
+ if (Number.isNaN(at)) return 'never';
102
+ const minutes = Math.round((now - at) / 60000);
103
+ if (minutes < 1) return 'just now';
104
+ if (minutes < 60) return `${minutes} min ago`;
105
+ const hours = Math.round(minutes / 60);
106
+ if (hours < 48) return `${hours}h ago`;
107
+ return `${Math.round(hours / 24)}d ago`;
108
+ }
109
+
110
+ function preview(document: SourceDocument, length = 180): string {
111
+ const text = document.body || document.sections[0]?.text || '';
112
+ if (!text) return '';
113
+ return text.length > length ? `${text.slice(0, length).trim()}…` : text;
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+
118
+ export function LibraryPage({
119
+ api,
120
+ scope,
121
+ initialDocumentId,
122
+ onSelectDocument,
123
+ pageSize = 20,
124
+ classNames = {},
125
+ }: LibraryPageProps) {
126
+ const cn = { ...DEFAULTS, ...classNames };
127
+
128
+ const [filters, setFilters] = React.useState<DocumentQuery>({ page: 1, pageSize });
129
+ const [draftSearch, setDraftSearch] = React.useState('');
130
+ const [documents, setDocuments] = React.useState<SourceDocument[]>([]);
131
+ const [count, setCount] = React.useState(0);
132
+ const [applied, setApplied] = React.useState<string[] | undefined>(undefined);
133
+ const [sources, setSources] = React.useState<SourceFreshness[]>([]);
134
+ const [parties, setParties] = React.useState<Party[]>([]);
135
+ const [selectedId, setSelectedId] = React.useState<string | null>(initialDocumentId ?? null);
136
+ const [selected, setSelected] = React.useState<SourceDocument | null>(null);
137
+ const [loading, setLoading] = React.useState(true);
138
+ const [loadError, setLoadError] = React.useState('');
139
+ /**
140
+ * LATCHED, not per-page. "The data API returned another workspace's rows" is a
141
+ * fact about the API, and paging forward past the offending page must not make
142
+ * the warning disappear as though it were fixed.
143
+ */
144
+ const [droppedOutOfScope, setDroppedOutOfScope] = React.useState(0);
145
+
146
+ // The reference-only surfaces (which sources exist, which parties exist) load
147
+ // once. They are what makes the filter controls DATA rather than a hardcoded
148
+ // vendor list — add a connector and its option appears with no code change.
149
+ React.useEffect(() => {
150
+ let live = true;
151
+ void (async () => {
152
+ const [freshness, partyPage] = await Promise.all([
153
+ api.listSources().catch(() => [] as SourceFreshness[]),
154
+ api.listParties({ page: 1, pageSize: 100 }).catch(() => ({
155
+ count: 0, next: null, previous: null, results: [] as Party[],
156
+ })),
157
+ ]);
158
+ if (!live) return;
159
+ setSources(freshness);
160
+ setParties(partyPage.results ?? []);
161
+ })();
162
+ return () => { live = false; };
163
+ }, [api]);
164
+
165
+ React.useEffect(() => {
166
+ let live = true;
167
+ setLoading(true);
168
+ void (async () => {
169
+ try {
170
+ const page = await api.listDocuments(filters);
171
+ if (!live) return;
172
+ // THE GUARD RUNS ON THE LIST, not only on the detail panel. A row from
173
+ // another workspace is a leak the moment it is drawn — its title and its
174
+ // preview text are already on the screen, and nobody has to click. The
175
+ // API scopes reads and RLS scopes them again; this is the third check,
176
+ // and a non-zero drop is stated rather than absorbed.
177
+ const { kept, dropped } = enforceScope(page.results ?? [], scope);
178
+ setDocuments(kept);
179
+ setDroppedOutOfScope((seen) => seen + dropped.length);
180
+ setCount(Math.max(0, (page.count ?? 0) - dropped.length));
181
+ setApplied(page.appliedFilters);
182
+ setLoadError('');
183
+ } catch (err) {
184
+ if (live) setLoadError(readFailureMessage(err));
185
+ } finally {
186
+ if (live) setLoading(false);
187
+ }
188
+ })();
189
+ return () => { live = false; };
190
+ }, [api, filters, scope]);
191
+
192
+ React.useEffect(() => {
193
+ if (!selectedId) { setSelected(null); return; }
194
+ let live = true;
195
+ void (async () => {
196
+ // Prefer the row already on screen; fall back to a fetch so a citation
197
+ // deep-link opens a record that is not on the current page.
198
+ const onPage = documents.find((document) => document.id === selectedId);
199
+ if (onPage) { setSelected(onPage); return; }
200
+ try {
201
+ const fetched = await api.getDocument(selectedId);
202
+ if (live) setSelected(fetched);
203
+ } catch {
204
+ if (live) setSelected(null);
205
+ }
206
+ })();
207
+ return () => { live = false; };
208
+ }, [api, selectedId, documents]);
209
+
210
+ const select = React.useCallback((id: string | null) => {
211
+ setSelectedId(id);
212
+ onSelectDocument?.(id);
213
+ }, [onSelectDocument]);
214
+
215
+ const update = React.useCallback((patch: Partial<DocumentQuery>) => {
216
+ // Any filter change resets to page 1 — page 3 of the old result set is not
217
+ // page 3 of the new one, and silently keeping it shows an empty list.
218
+ setFilters((prev) => ({ ...prev, ...patch, page: patch.page ?? 1 }));
219
+ }, []);
220
+
221
+ const page = filters.page ?? 1;
222
+ const size = filters.pageSize ?? pageSize;
223
+ const lastPage = Math.max(1, Math.ceil(count / size));
224
+ const now = Date.now();
225
+
226
+ /**
227
+ * A filter the SERVER did not apply. Rendering the unfiltered list under a
228
+ * filled-in search box is a lie the user cannot catch, so it is stated.
229
+ */
230
+ const ignored = React.useMemo(() => {
231
+ if (!applied) return [] as string[];
232
+ const requested: Array<[string, unknown]> = [
233
+ ['search', filters.search],
234
+ ['source', filters.source],
235
+ ['party', filters.partyId],
236
+ ['date', filters.occurredAfter || filters.occurredBefore],
237
+ ];
238
+ const keyFor: Record<string, string[]> = {
239
+ search: ['search'], source: ['source'], party: ['partyId'],
240
+ date: ['occurredAfter', 'occurredBefore'],
241
+ };
242
+ return requested
243
+ .filter(([name, value]) => value && !keyFor[name].some((key) => applied.includes(key)))
244
+ .map(([name]) => name);
245
+ }, [applied, filters]);
246
+
247
+ if (loadError) {
248
+ return <p className={cn.error} role="alert">{loadError}</p>;
249
+ }
250
+
251
+ return (
252
+ <div className={cn.root}>
253
+ <FreshnessStrip sources={sources} classNames={cn} now={now} />
254
+
255
+ <section className={cn.section}>
256
+ <form
257
+ className="flex flex-wrap items-end gap-2"
258
+ onSubmit={(event) => { event.preventDefault(); update({ search: draftSearch || undefined }); }}
259
+ >
260
+ <label className="flex flex-col gap-1">
261
+ <span className="text-xs font-medium text-gray-700">Search</span>
262
+ <input
263
+ className={cn.input}
264
+ type="search"
265
+ value={draftSearch}
266
+ placeholder="Search everything pulled in"
267
+ onChange={(event) => setDraftSearch(event.target.value)}
268
+ />
269
+ </label>
270
+
271
+ <label className="flex flex-col gap-1">
272
+ <span className="text-xs font-medium text-gray-700">Source</span>
273
+ <select
274
+ className={cn.input}
275
+ value={filters.source ?? ''}
276
+ onChange={(event) => update({ source: event.target.value || undefined })}
277
+ >
278
+ <option value="">All sources</option>
279
+ {/* Options are DATA. A fourth connector appears here by existing. */}
280
+ {sources.map((source) => (
281
+ <option key={source.source} value={source.source}>{source.label}</option>
282
+ ))}
283
+ </select>
284
+ </label>
285
+
286
+ <label className="flex flex-col gap-1">
287
+ <span className="text-xs font-medium text-gray-700">Party</span>
288
+ <select
289
+ className={cn.input}
290
+ value={filters.partyId ?? ''}
291
+ onChange={(event) => update({ partyId: event.target.value || undefined })}
292
+ >
293
+ <option value="">Anyone</option>
294
+ {parties.map((party) => (
295
+ <option key={party.id} value={party.id}>{party.name}</option>
296
+ ))}
297
+ </select>
298
+ </label>
299
+
300
+ <label className="flex flex-col gap-1">
301
+ <span className="text-xs font-medium text-gray-700">From</span>
302
+ <input
303
+ className={cn.input}
304
+ type="date"
305
+ value={(filters.occurredAfter ?? '').slice(0, 10)}
306
+ onChange={(event) => update({
307
+ occurredAfter: event.target.value ? `${event.target.value}T00:00:00Z` : undefined,
308
+ })}
309
+ />
310
+ </label>
311
+
312
+ <label className="flex flex-col gap-1">
313
+ <span className="text-xs font-medium text-gray-700">To</span>
314
+ <input
315
+ className={cn.input}
316
+ type="date"
317
+ value={(filters.occurredBefore ?? '').slice(0, 10)}
318
+ onChange={(event) => update({
319
+ occurredBefore: event.target.value ? `${event.target.value}T23:59:59Z` : undefined,
320
+ })}
321
+ />
322
+ </label>
323
+
324
+ <button type="submit" className={cn.button}>Search</button>
325
+ <button
326
+ type="button"
327
+ className={cn.button}
328
+ onClick={() => { setDraftSearch(''); setFilters({ page: 1, pageSize: size }); }}
329
+ >
330
+ Clear
331
+ </button>
332
+ </form>
333
+
334
+ {ignored.length ? (
335
+ <p className={cn.warning} role="status">
336
+ This workspace’s data API does not filter by {ignored.join(', ')} yet, so the list below
337
+ is not narrowed by {ignored.length > 1 ? 'those' : 'that'}.
338
+ </p>
339
+ ) : null}
340
+
341
+ {droppedOutOfScope > 0 ? (
342
+ // Good news about the guard, bad news about the API. Both are worth
343
+ // seeing: a data plane returning other workspaces' rows is a bug that
344
+ // silence would let run for months.
345
+ <p className={cn.error} role="alert">
346
+ This workspace’s data API returned {droppedOutOfScope} record
347
+ {droppedOutOfScope === 1 ? '' : 's'} belonging to another workspace. They were hidden,
348
+ and the record counts below exclude them.
349
+ </p>
350
+ ) : null}
351
+ </section>
352
+
353
+ <section className={cn.section}>
354
+ <div className="flex items-baseline justify-between">
355
+ <h2 className={cn.heading}>Library</h2>
356
+ <p className={cn.muted}>
357
+ {count} record{count === 1 ? '' : 's'}
358
+ {count > 0 ? ` · page ${page} of ${lastPage}` : ''}
359
+ </p>
360
+ </div>
361
+
362
+ {loading && documents.length === 0 ? <p className={cn.muted}>Loading…</p> : null}
363
+
364
+ {!loading && documents.length === 0 ? (
365
+ <EmptyState hasFilters={Boolean(filters.search || filters.source || filters.partyId || filters.occurredAfter || filters.occurredBefore)} sources={sources} classNames={cn} />
366
+ ) : null}
367
+
368
+ <div className="space-y-2">
369
+ {documents.map((document) => (
370
+ <button
371
+ key={document.id}
372
+ type="button"
373
+ className={cn.row}
374
+ aria-expanded={selectedId === document.id}
375
+ onClick={() => select(selectedId === document.id ? null : document.id)}
376
+ >
377
+ <div className="flex flex-wrap items-center gap-2">
378
+ {/* The source is a BADGE on every row, never a hidden column:
379
+ "the AI learned this from a meeting" and "…from a contract in
380
+ Drive" are different claims to whoever reads a draft later. */}
381
+ <span className={cn.badge}>{document.sourceLabel || document.source}</span>
382
+ <span className="font-medium text-gray-900">{document.title}</span>
383
+ </div>
384
+ <p className={cn.muted}>
385
+ {formatDate(document.occurredAt)}
386
+ {document.participants.length
387
+ ? ` · ${document.participants.map((party) => party.name).join(', ')}`
388
+ : ''}
389
+ {document.sections.length
390
+ ? ` · ${document.sections.map((section) => section.label).join(', ')}`
391
+ : ''}
392
+ </p>
393
+ {preview(document) ? (
394
+ <p className="mt-1 text-sm text-gray-600">{preview(document)}</p>
395
+ ) : (
396
+ <p className={cn.muted}>No text was captured for this record.</p>
397
+ )}
398
+ </button>
399
+ ))}
400
+ </div>
401
+
402
+ {count > size ? (
403
+ <div className="flex items-center gap-2">
404
+ <button
405
+ type="button"
406
+ className={cn.button}
407
+ disabled={page <= 1 || loading}
408
+ onClick={() => setFilters((prev) => ({ ...prev, page: Math.max(1, page - 1) }))}
409
+ >
410
+ Previous
411
+ </button>
412
+ <button
413
+ type="button"
414
+ className={cn.button}
415
+ disabled={page >= lastPage || loading}
416
+ onClick={() => setFilters((prev) => ({ ...prev, page: page + 1 }))}
417
+ >
418
+ Next
419
+ </button>
420
+ {/* Paging is a SERVER round trip (CLAUDE.md rule 8) — the browser
421
+ never holds the corpus, so this keeps working at any size. */}
422
+ <span className={cn.muted}>Page {page} of {lastPage}</span>
423
+ </div>
424
+ ) : null}
425
+ </section>
426
+
427
+ {selected ? (
428
+ <DocumentDetail
429
+ document={selected}
430
+ scope={scope}
431
+ classNames={cn}
432
+ onClose={() => select(null)}
433
+ onFilterByParty={(partyId) => update({ partyId })}
434
+ />
435
+ ) : null}
436
+ </div>
437
+ );
438
+ }
439
+
440
+ // ---------------------------------------------------------------------------
441
+ // freshness
442
+ // ---------------------------------------------------------------------------
443
+
444
+ const FRESHNESS_WORDS: Record<SourceFreshness['status'], string> = {
445
+ ok: 'Up to date',
446
+ stale: 'Stale',
447
+ error: 'Failing',
448
+ never: 'Nothing yet',
449
+ };
450
+
451
+ function FreshnessStrip({
452
+ sources, classNames: cn, now,
453
+ }: { sources: SourceFreshness[]; classNames: Record<LibrarySlot, string>; now: number }) {
454
+ if (sources.length === 0) return null;
455
+ return (
456
+ <section className={cn.section} aria-label="Source freshness">
457
+ <div className="flex flex-wrap gap-2">
458
+ {sources.map((source) => (
459
+ <div key={source.source} className={cn.card}>
460
+ <div className="flex items-center gap-2">
461
+ <span className={cn.badge}>{source.label}</span>
462
+ <span
463
+ className={source.status === 'ok' ? 'text-xs text-gray-500' : 'text-xs font-medium text-amber-700'}
464
+ >
465
+ {FRESHNESS_WORDS[source.status]}
466
+ </span>
467
+ </div>
468
+ <p className={cn.muted}>
469
+ {source.documentCount} record{source.documentCount === 1 ? '' : 's'} · synced{' '}
470
+ {relative(source.lastSyncedAt, now)}
471
+ </p>
472
+ {/* A source that is failing says so HERE, next to the corpus it is
473
+ supposed to be filling — not on a settings page nobody opens. */}
474
+ {source.detail ? <p className="text-xs text-amber-700">{source.detail}</p> : null}
475
+ </div>
476
+ ))}
477
+ </div>
478
+ </section>
479
+ );
480
+ }
481
+
482
+ // ---------------------------------------------------------------------------
483
+ // empty state
484
+ // ---------------------------------------------------------------------------
485
+
486
+ /**
487
+ * Three different nothings, because they need three different actions: no
488
+ * sources connected, sources connected but nothing arrived, and a filter that
489
+ * matched nothing. One generic "No results" would send the user to the wrong
490
+ * place in two of the three.
491
+ */
492
+ function EmptyState({
493
+ hasFilters, sources, classNames: cn,
494
+ }: { hasFilters: boolean; sources: SourceFreshness[]; classNames: Record<LibrarySlot, string> }) {
495
+ if (hasFilters) {
496
+ return <p className={cn.muted}>Nothing matches those filters. Clear them to see everything.</p>;
497
+ }
498
+ if (sources.length === 0) {
499
+ return (
500
+ <p className={cn.muted}>
501
+ Nothing has been pulled in yet. Connect a source and its records appear here.
502
+ </p>
503
+ );
504
+ }
505
+ const failing = sources.filter((source) => source.status === 'error');
506
+ if (failing.length) {
507
+ return (
508
+ <p className={cn.warning} role="status">
509
+ Nothing has arrived yet, and {failing.map((source) => source.label).join(', ')}{' '}
510
+ {failing.length === 1 ? 'is' : 'are'} failing to sync.
511
+ </p>
512
+ );
513
+ }
514
+ return <p className={cn.muted}>Your sources are connected but have not delivered any records yet.</p>;
515
+ }
516
+
517
+ // ---------------------------------------------------------------------------
518
+ // detail
519
+ // ---------------------------------------------------------------------------
520
+
521
+ function DocumentDetail({
522
+ document, scope, classNames: cn, onClose, onFilterByParty,
523
+ }: {
524
+ document: SourceDocument;
525
+ scope: CorpusScope;
526
+ classNames: Record<LibrarySlot, string>;
527
+ onClose: () => void;
528
+ onFilterByParty: (partyId: string) => void;
529
+ }) {
530
+ // Defense in depth. The API scopes reads and Postgres RLS scopes them again,
531
+ // but a record that reached this component from the wrong workspace must not
532
+ // render — a leak is worst at the moment it is displayed as fact.
533
+ if (document.scope?.orgId && document.scope.orgId !== scope.orgId) {
534
+ return (
535
+ <section className={cn.card} aria-label="Record">
536
+ <p className={cn.error} role="alert">This record belongs to another workspace and was not shown.</p>
537
+ </section>
538
+ );
539
+ }
540
+
541
+ return (
542
+ <section className={cn.card} aria-label="Record detail">
543
+ <div className="flex items-start justify-between gap-4">
544
+ <div>
545
+ <div className="flex flex-wrap items-center gap-2">
546
+ <span className={cn.badge}>{document.sourceLabel || document.source}</span>
547
+ <h3 className={cn.heading}>{document.title}</h3>
548
+ </div>
549
+ <p className={cn.muted}>
550
+ {formatDate(document.occurredAt)}
551
+ {document.sourceRef ? ` · ${document.sourceRef}` : ''}
552
+ </p>
553
+ </div>
554
+ <button type="button" className={cn.button} onClick={onClose}>Close</button>
555
+ </div>
556
+
557
+ {document.url ? (
558
+ <p className="mt-2">
559
+ {/* Open it where it LIVES. A corpus that cannot point back at the
560
+ original is a copy nobody can check. */}
561
+ <a className="text-sm text-blue-700 underline" href={document.url} target="_blank" rel="noreferrer">
562
+ Open in {document.sourceLabel || document.source}
563
+ </a>
564
+ </p>
565
+ ) : null}
566
+
567
+ {document.participants.length ? (
568
+ <div className="mt-3">
569
+ <p className="text-sm font-medium text-gray-900">Parties</p>
570
+ <div className="mt-1 flex flex-wrap gap-2">
571
+ {document.participants.map((party) => (
572
+ <button
573
+ key={party.id}
574
+ type="button"
575
+ className={cn.badge}
576
+ onClick={() => onFilterByParty(party.id)}
577
+ title={`Show everything involving ${party.name}`}
578
+ >
579
+ {party.name}{party.kind ? ` · ${party.kind}` : ''}
580
+ </button>
581
+ ))}
582
+ </div>
583
+ </div>
584
+ ) : null}
585
+
586
+ {document.body ? (
587
+ <div className="mt-3">
588
+ <p className="text-sm font-medium text-gray-900">Summary</p>
589
+ <p className="mt-1 whitespace-pre-wrap text-sm text-gray-700">{document.body}</p>
590
+ </div>
591
+ ) : null}
592
+
593
+ {/* Long-form under the SOURCE's own name for it — "Transcript" for a
594
+ meeting, "Extracted text" for a PDF. This component does not know
595
+ which is which, and must not. */}
596
+ {document.sections.map((section) => (
597
+ <details key={section.key} className="mt-3">
598
+ <summary className="cursor-pointer text-sm font-medium text-gray-900">{section.label}</summary>
599
+ <pre className="mt-2 max-h-96 overflow-auto whitespace-pre-wrap rounded bg-gray-50 p-2 text-xs text-gray-700">
600
+ {section.text}
601
+ </pre>
602
+ </details>
603
+ ))}
604
+
605
+ {!document.body && document.sections.length === 0 ? (
606
+ <p className={`mt-3 ${cn.muted}`}>
607
+ This record arrived with no text. Its source may have deleted the content upstream.
608
+ </p>
609
+ ) : null}
610
+ </section>
611
+ );
612
+ }