@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.
- package/package.json +11 -4
- package/src/__tests__/corpus-ask.test.ts +279 -0
- package/src/__tests__/corpus-pages.test.tsx +479 -0
- package/src/__tests__/corpus-scope.test.ts +216 -0
- package/src/components/index.ts +5 -0
- package/src/components/pages/AskPage.tsx +384 -0
- package/src/components/pages/LibraryPage.tsx +612 -0
- package/src/corpus/ask.ts +321 -0
- package/src/corpus/client.ts +196 -0
- package/src/corpus/entities.ts +346 -0
- package/src/corpus/fixtures.ts +486 -0
- package/src/corpus/index.ts +79 -0
- package/src/corpus/llm.ts +197 -0
- package/src/corpus/retrieval.ts +237 -0
- package/src/corpus/types.ts +354 -0
- package/src/index.ts +9 -0
|
@@ -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
|
+
}
|