@startsimpli/integrations 0.2.33 → 0.2.35
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 +1 -1
- package/src/__tests__/connection-activity.test.tsx +160 -0
- package/src/__tests__/corpus-folders.test.tsx +727 -0
- package/src/__tests__/corpus-pages.test.tsx +4 -4
- package/src/__tests__/library-failure-reason.test.tsx +178 -0
- package/src/components/FolderTree.tsx +425 -0
- package/src/components/index.ts +6 -0
- package/src/components/pages/ConnectionsPage.tsx +174 -68
- package/src/components/pages/LibraryPage.tsx +333 -35
- package/src/corpus/client.ts +123 -9
- package/src/corpus/entities.ts +75 -0
- package/src/corpus/fixtures.ts +263 -1
- package/src/corpus/index.ts +8 -1
- package/src/corpus/types.ts +121 -0
|
@@ -27,6 +27,9 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import * as React from 'react';
|
|
29
29
|
|
|
30
|
+
import { FolderTree, folderTrail } from '../FolderTree';
|
|
31
|
+
import { folderName } from '../../corpus/entities';
|
|
32
|
+
import type { FolderTreeSlot } from '../FolderTree';
|
|
30
33
|
import { Markdown, stripMarkdown } from '../Markdown';
|
|
31
34
|
import { enforceScope } from '../../corpus/retrieval';
|
|
32
35
|
import type {
|
|
@@ -46,8 +49,34 @@ export interface LibraryPageProps {
|
|
|
46
49
|
initialDocumentId?: string;
|
|
47
50
|
/** Told whenever the open record changes, so an app can put it in the URL. */
|
|
48
51
|
onSelectDocument?: (documentId: string | null) => void;
|
|
52
|
+
/** Open straight into a folder — how a link to a location lands. */
|
|
53
|
+
initialFolder?: string;
|
|
54
|
+
/** Told whenever the open folder changes, so an app can put it in the URL. */
|
|
55
|
+
onSelectFolder?: (folder: string | null) => void;
|
|
56
|
+
/**
|
|
57
|
+
* PULL THE TEXT OF ONE RECORD. Supply it and the control on a text-less row is
|
|
58
|
+
* live; leave it out and the same control is there and DISABLED.
|
|
59
|
+
*
|
|
60
|
+
* IT IS A CONTROL EITHER WAY, and that is the whole point of the seam
|
|
61
|
+
* (Quinn, 2026-08-27, on the sentence this replaced: "don't say stupid shit
|
|
62
|
+
* like 'no text blah blah'. either have a button to pull the text, or have it
|
|
63
|
+
* grayed out if we can't do the action. simple ui."). A sentence in the place
|
|
64
|
+
* a control belongs tells a reader what happened; a greyed-out control tells
|
|
65
|
+
* them what they may do, which is the question they were actually asking.
|
|
66
|
+
*
|
|
67
|
+
* NOTHING SUPPLIES IT TODAY, and that is correct rather than a stub. Content
|
|
68
|
+
* is OPT-IN and never the default (bd startsim-h6xnu): the index owes what
|
|
69
|
+
* exists, where it lives and findability, and pulling the bytes of every file
|
|
70
|
+
* is the expensive, privacy-loaded half nobody asked for. The route that would
|
|
71
|
+
* fill this in is bd startsim-99g8.2 and it is deliberately unshipped, so on
|
|
72
|
+
* today's tenant the control is disabled — which is the honest answer to "can
|
|
73
|
+
* I read this .ttf".
|
|
74
|
+
*/
|
|
75
|
+
onRequestText?: (document: SourceDocument) => void | Promise<void>;
|
|
49
76
|
pageSize?: number;
|
|
50
77
|
classNames?: Partial<Record<LibrarySlot, string>>;
|
|
78
|
+
/** Theme slots for the folder tree, passed straight through. */
|
|
79
|
+
folderClassNames?: Partial<Record<FolderTreeSlot, string>>;
|
|
51
80
|
}
|
|
52
81
|
|
|
53
82
|
type LibrarySlot =
|
|
@@ -169,12 +198,51 @@ export function LibraryPage({
|
|
|
169
198
|
scope,
|
|
170
199
|
initialDocumentId,
|
|
171
200
|
onSelectDocument,
|
|
201
|
+
initialFolder,
|
|
202
|
+
onSelectFolder,
|
|
203
|
+
onRequestText,
|
|
172
204
|
pageSize = 20,
|
|
173
205
|
classNames = {},
|
|
206
|
+
folderClassNames = {},
|
|
174
207
|
}: LibraryPageProps) {
|
|
175
208
|
const cn = { ...DEFAULTS, ...classNames };
|
|
176
209
|
|
|
177
210
|
const [filters, setFilters] = React.useState<DocumentQuery>({ page: 1, pageSize });
|
|
211
|
+
/**
|
|
212
|
+
* THE OPEN FOLDER, or `null` for the whole corpus.
|
|
213
|
+
*
|
|
214
|
+
* `null` IS NOT A BUCKET AND MUST NOT BECOME ONE. The facet read enumerates
|
|
215
|
+
* values of `path` and skips records that have none, so a meeting note and an
|
|
216
|
+
* email are in NO folder — which on today's tandem is most of the corpus. The
|
|
217
|
+
* top of the tree therefore clears the path filter rather than narrowing to
|
|
218
|
+
* some invented "unfiled", and every record stays one click from the top the
|
|
219
|
+
* way it is in any file browser.
|
|
220
|
+
*/
|
|
221
|
+
const [folder, setFolder] = React.useState<string | null>(initialFolder ?? null);
|
|
222
|
+
/** Set once the corpus proves it cannot enumerate folders; see `FolderTree`. */
|
|
223
|
+
const [noTree, setNoTree] = React.useState(false);
|
|
224
|
+
/**
|
|
225
|
+
* Whether an open folder means IT AND EVERYTHING BELOW IT. Default yes, and
|
|
226
|
+
* the default is the whole point.
|
|
227
|
+
*
|
|
228
|
+
* THE NUMBER THE READER JUST CLICKED HAS TO BE THE LIST THEY GET. A folder's
|
|
229
|
+
* count is DESCENDANTS — the tenant says so outright, because that is the
|
|
230
|
+
* number a person reading a collapsed folder wants — so a list bounded to the
|
|
231
|
+
* exact shelf disagrees with it the moment a folder is a container. On the
|
|
232
|
+
* live tandem corpus that is the two biggest folders: `/ERA22 Shared` (142)
|
|
233
|
+
* and `/Demo Day Presentations` (37) both hold their documents in subfolders.
|
|
234
|
+
* Bounded exactly, clicking either showed "0 records" beside a sidebar reading
|
|
235
|
+
* 142, under the sentence "Nothing matches those filters. Clear them to see
|
|
236
|
+
* everything." — filters the reader never set, in the place a control belongs.
|
|
237
|
+
* That is the same fault as the row sentence this screen just removed.
|
|
238
|
+
*
|
|
239
|
+
* IT IS A CONTROL, so the other reading stays available: uncheck it and the
|
|
240
|
+
* list is the shelf. What it is not is a hidden mode — the bound used to
|
|
241
|
+
* depend on whether the search box had a term in it, which meant the same
|
|
242
|
+
* click answered two different questions depending on state the reader was not
|
|
243
|
+
* looking at.
|
|
244
|
+
*/
|
|
245
|
+
const [includeSubfolders, setIncludeSubfolders] = React.useState(true);
|
|
178
246
|
const [draftSearch, setDraftSearch] = React.useState('');
|
|
179
247
|
const [documents, setDocuments] = React.useState<SourceDocument[]>([]);
|
|
180
248
|
const [count, setCount] = React.useState(0);
|
|
@@ -218,12 +286,25 @@ export function LibraryPage({
|
|
|
218
286
|
return () => { live = false; };
|
|
219
287
|
}, [api]);
|
|
220
288
|
|
|
289
|
+
/**
|
|
290
|
+
* The list query: the filter bar PLUS the open folder, bounded the way the
|
|
291
|
+
* reader asked for it — see `includeSubfolders` for why the default is the
|
|
292
|
+
* subtree and not the shelf.
|
|
293
|
+
*/
|
|
294
|
+
const query = React.useMemo<DocumentQuery>(() => {
|
|
295
|
+
if (!folder) return filters;
|
|
296
|
+
// ONE OF THE TWO, NEVER BOTH: the list endpoint ANDs its filters, so sending
|
|
297
|
+
// both would intersect a folder with its own subtree and quietly answer with
|
|
298
|
+
// the shelf.
|
|
299
|
+
return includeSubfolders ? { ...filters, underFolder: folder } : { ...filters, folder };
|
|
300
|
+
}, [filters, folder, includeSubfolders]);
|
|
301
|
+
|
|
221
302
|
React.useEffect(() => {
|
|
222
303
|
let live = true;
|
|
223
304
|
setLoading(true);
|
|
224
305
|
void (async () => {
|
|
225
306
|
try {
|
|
226
|
-
const page = await api.listDocuments(
|
|
307
|
+
const page = await api.listDocuments(query);
|
|
227
308
|
if (!live) return;
|
|
228
309
|
// THE GUARD RUNS ON THE LIST, not only on the detail panel. A row from
|
|
229
310
|
// another workspace is a leak the moment it is drawn — its title and its
|
|
@@ -243,7 +324,7 @@ export function LibraryPage({
|
|
|
243
324
|
}
|
|
244
325
|
})();
|
|
245
326
|
return () => { live = false; };
|
|
246
|
-
}, [api,
|
|
327
|
+
}, [api, query, scope]);
|
|
247
328
|
|
|
248
329
|
React.useEffect(() => {
|
|
249
330
|
if (!selectedId) { setSelected(null); setSelectError(''); return; }
|
|
@@ -279,6 +360,25 @@ export function LibraryPage({
|
|
|
279
360
|
setFilters((prev) => ({ ...prev, ...patch, page: patch.page ?? 1 }));
|
|
280
361
|
}, []);
|
|
281
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Move to a folder. Same page-1 reset as any other narrowing, and for the
|
|
365
|
+
* same reason: page 3 of `/Contracts` is not page 3 of the corpus.
|
|
366
|
+
*/
|
|
367
|
+
const openFolder = React.useCallback((next: string | null) => {
|
|
368
|
+
setFolder(next);
|
|
369
|
+
setFilters((prev) => ({ ...prev, page: 1 }));
|
|
370
|
+
// AND THE BOUND RESETS WITH IT. "Only this shelf" is a thing the reader
|
|
371
|
+
// wanted to know about THAT folder, not a mode they signed up to carry
|
|
372
|
+
// around: left sticky, unchecking it on a container like `/Decks` and then
|
|
373
|
+
// clicking `/Contracts` — which does have files of its own — lists 2 of its
|
|
374
|
+
// 7 under a sidebar reading 7, which is the disagreement this screen just
|
|
375
|
+
// spent a commit removing, reachable in two clicks. Same reasoning as the
|
|
376
|
+
// page reset above: the answer to the old question is not the answer to the
|
|
377
|
+
// new one, and the control is one click away for anyone who wants it back.
|
|
378
|
+
setIncludeSubfolders(true);
|
|
379
|
+
onSelectFolder?.(next);
|
|
380
|
+
}, [onSelectFolder]);
|
|
381
|
+
|
|
282
382
|
const page = filters.page ?? 1;
|
|
283
383
|
const size = filters.pageSize ?? pageSize;
|
|
284
384
|
const lastPage = Math.max(1, Math.ceil(count / size));
|
|
@@ -295,15 +395,20 @@ export function LibraryPage({
|
|
|
295
395
|
['source', filters.source],
|
|
296
396
|
['party', filters.partyId],
|
|
297
397
|
['date', filters.occurredAfter || filters.occurredBefore],
|
|
398
|
+
// THE ONE THAT MATTERS MOST HERE. A tree the server cannot narrow by is
|
|
399
|
+
// worse than no tree: the reader clicks `/Contracts`, the list does not
|
|
400
|
+
// change, and nothing on the screen says why. Same rule as the search box.
|
|
401
|
+
['folder', query.folder || query.underFolder],
|
|
298
402
|
];
|
|
299
403
|
const keyFor: Record<string, string[]> = {
|
|
300
404
|
search: ['search'], source: ['source'], party: ['partyId'],
|
|
301
405
|
date: ['occurredAfter', 'occurredBefore'],
|
|
406
|
+
folder: ['folder', 'underFolder'],
|
|
302
407
|
};
|
|
303
408
|
return requested
|
|
304
409
|
.filter(([name, value]) => value && !keyFor[name].some((key) => applied.includes(key)))
|
|
305
410
|
.map(([name]) => name);
|
|
306
|
-
}, [applied, filters]);
|
|
411
|
+
}, [applied, filters, query]);
|
|
307
412
|
|
|
308
413
|
if (loadError) {
|
|
309
414
|
return <p className={cn.error} role="alert">{loadError}</p>;
|
|
@@ -386,7 +491,16 @@ export function LibraryPage({
|
|
|
386
491
|
<button
|
|
387
492
|
type="button"
|
|
388
493
|
className={cn.button}
|
|
389
|
-
onClick={() => {
|
|
494
|
+
onClick={() => {
|
|
495
|
+
setDraftSearch('');
|
|
496
|
+
setFilters({ page: 1, pageSize: size });
|
|
497
|
+
setIncludeSubfolders(true);
|
|
498
|
+
// "Clear" resets the VIEW, and the open folder is part of the view
|
|
499
|
+
// a reader is looking at. Leaving them inside a folder with an
|
|
500
|
+
// emptied filter bar is the same half-answer as leaving the search
|
|
501
|
+
// term in the box.
|
|
502
|
+
openFolder(null);
|
|
503
|
+
}}
|
|
390
504
|
>
|
|
391
505
|
Clear
|
|
392
506
|
</button>
|
|
@@ -411,52 +525,169 @@ export function LibraryPage({
|
|
|
411
525
|
) : null}
|
|
412
526
|
</section>
|
|
413
527
|
|
|
528
|
+
{/* ONE LIST, CATEGORISED BY FOLDER STRUCTURE (bd startsim-nbhno). The tree
|
|
529
|
+
is not a second surface and not a second taxonomy — it is the shelf the
|
|
530
|
+
one list is standing on, which is the arrangement every computer user
|
|
531
|
+
already knows. When the corpus cannot enumerate folders the aside
|
|
532
|
+
simply is not there and this is the single-column list it has always
|
|
533
|
+
been; a half-drawn tree would be worse than none. */}
|
|
534
|
+
<div className={noTree ? '' : 'grid gap-6 md:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]'}>
|
|
535
|
+
{noTree ? null : (
|
|
536
|
+
<aside className={`${cn.card} h-max`}>
|
|
537
|
+
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">
|
|
538
|
+
Folders
|
|
539
|
+
</h2>
|
|
540
|
+
<FolderTree
|
|
541
|
+
api={api}
|
|
542
|
+
selected={folder}
|
|
543
|
+
onSelect={openFolder}
|
|
544
|
+
classNames={folderClassNames}
|
|
545
|
+
onUnavailable={() => setNoTree(true)}
|
|
546
|
+
/>
|
|
547
|
+
</aside>
|
|
548
|
+
)}
|
|
549
|
+
|
|
414
550
|
<section className={cn.section}>
|
|
415
|
-
<div className="flex items-baseline justify-between">
|
|
416
|
-
<h2 className={cn.heading}>Library</h2>
|
|
551
|
+
<div className="flex items-baseline justify-between gap-4">
|
|
552
|
+
<h2 className={cn.heading}>{folder ? folderName(folder) : 'Library'}</h2>
|
|
417
553
|
<p className={cn.muted}>
|
|
418
554
|
{count} record{count === 1 ? '' : 's'}
|
|
419
555
|
{count > 0 ? ` · page ${page} of ${lastPage}` : ''}
|
|
420
556
|
</p>
|
|
421
557
|
</div>
|
|
422
558
|
|
|
559
|
+
{/* WHERE YOU ARE. Derived from the open path, which the server returned;
|
|
560
|
+
every crumb re-asks the server for that container rather than
|
|
561
|
+
reusing anything already on screen. */}
|
|
562
|
+
{folder ? (
|
|
563
|
+
<nav aria-label="Folder path" className="flex flex-wrap items-center gap-1 text-sm">
|
|
564
|
+
<button
|
|
565
|
+
type="button"
|
|
566
|
+
className="text-gray-500 underline underline-offset-2 hover:text-gray-900"
|
|
567
|
+
onClick={() => openFolder(null)}
|
|
568
|
+
>
|
|
569
|
+
All documents
|
|
570
|
+
</button>
|
|
571
|
+
{folderTrail(folder).map((crumb, index, all) => (
|
|
572
|
+
<React.Fragment key={crumb.path}>
|
|
573
|
+
<span aria-hidden="true" className="text-gray-300">/</span>
|
|
574
|
+
{index === all.length - 1 ? (
|
|
575
|
+
<span className="font-medium text-gray-900" aria-current="page">{crumb.name}</span>
|
|
576
|
+
) : (
|
|
577
|
+
<button
|
|
578
|
+
type="button"
|
|
579
|
+
className="text-gray-500 underline underline-offset-2 hover:text-gray-900"
|
|
580
|
+
onClick={() => openFolder(crumb.path)}
|
|
581
|
+
>
|
|
582
|
+
{crumb.name}
|
|
583
|
+
</button>
|
|
584
|
+
)}
|
|
585
|
+
</React.Fragment>
|
|
586
|
+
))}
|
|
587
|
+
{/* THE CONTROL THAT MAKES THE BOUND VISIBLE, rather than a mode the
|
|
588
|
+
reader has to infer. Checked, the count beside the folder in the
|
|
589
|
+
tree is the count above the list; unchecked, the list is the
|
|
590
|
+
shelf and the difference is on screen as a thing they did. */}
|
|
591
|
+
<label className="ml-3 flex items-center gap-1.5 text-xs text-gray-600">
|
|
592
|
+
<input
|
|
593
|
+
type="checkbox"
|
|
594
|
+
checked={includeSubfolders}
|
|
595
|
+
onChange={(event) => {
|
|
596
|
+
setIncludeSubfolders(event.target.checked);
|
|
597
|
+
// A narrowing is a filter change: page 3 of the subtree is not
|
|
598
|
+
// page 3 of the shelf.
|
|
599
|
+
setFilters((prev) => ({ ...prev, page: 1 }));
|
|
600
|
+
}}
|
|
601
|
+
/>
|
|
602
|
+
Include subfolders
|
|
603
|
+
</label>
|
|
604
|
+
</nav>
|
|
605
|
+
) : null}
|
|
606
|
+
|
|
423
607
|
{loading && documents.length === 0 ? <p className={cn.muted}>Loading…</p> : null}
|
|
424
608
|
|
|
425
609
|
{!loading && documents.length === 0 ? (
|
|
426
|
-
|
|
610
|
+
// A FOLDER THAT HOLDS ITS DOCUMENTS ONE LEVEL DOWN, with the reader
|
|
611
|
+
// having narrowed to the shelf. Zero is the true answer to what they
|
|
612
|
+
// asked, but "Nothing matches those filters. Clear them to see
|
|
613
|
+
// everything." is the wrong advice — there is no filter to clear, and
|
|
614
|
+
// the remedy is one specific thing. So the remedy IS what stands here:
|
|
615
|
+
// the control, in the place the sentence was. Same rule as the row's
|
|
616
|
+
// Get text button, and the same reason.
|
|
617
|
+
folder && !includeSubfolders ? (
|
|
618
|
+
<button
|
|
619
|
+
type="button"
|
|
620
|
+
className={cn.button}
|
|
621
|
+
onClick={() => {
|
|
622
|
+
setIncludeSubfolders(true);
|
|
623
|
+
setFilters((prev) => ({ ...prev, page: 1 }));
|
|
624
|
+
}}
|
|
625
|
+
>
|
|
626
|
+
Include subfolders
|
|
627
|
+
</button>
|
|
628
|
+
) : (
|
|
629
|
+
<EmptyState
|
|
630
|
+
hasFilters={Boolean(
|
|
631
|
+
filters.search || filters.source || filters.partyId
|
|
632
|
+
|| filters.occurredAfter || filters.occurredBefore || folder,
|
|
633
|
+
)}
|
|
634
|
+
sources={sources}
|
|
635
|
+
classNames={cn}
|
|
636
|
+
/>
|
|
637
|
+
)
|
|
427
638
|
) : null}
|
|
428
639
|
|
|
429
640
|
<div className="space-y-2">
|
|
430
641
|
{documents.map((document) => (
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
{
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
<
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
{
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
642
|
+
// A DIV WRAPPING TWO BUTTONS, not one button wrapping everything.
|
|
643
|
+
// The row now carries an action of its own, and a button inside a
|
|
644
|
+
// button is invalid HTML that browsers resolve by dropping one of
|
|
645
|
+
// them — usually the one you wanted.
|
|
646
|
+
<div key={document.id} className={`${cn.row} flex items-start gap-3`}>
|
|
647
|
+
<button
|
|
648
|
+
type="button"
|
|
649
|
+
className="min-w-0 flex-1 text-left"
|
|
650
|
+
aria-expanded={selectedId === document.id}
|
|
651
|
+
onClick={() => select(selectedId === document.id ? null : document.id)}
|
|
652
|
+
>
|
|
653
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
654
|
+
{/* The source is a BADGE on every row, never a hidden column:
|
|
655
|
+
"the AI learned this from a meeting" and "…from a contract
|
|
656
|
+
in Drive" are different claims to whoever reads a draft
|
|
657
|
+
later. */}
|
|
658
|
+
<span className={cn.badge}>{document.sourceLabel || document.source}</span>
|
|
659
|
+
<span className="font-medium text-gray-900">{document.title}</span>
|
|
660
|
+
</div>
|
|
661
|
+
<p className={cn.muted}>
|
|
662
|
+
{formatDate(document.occurredAt)}
|
|
663
|
+
{document.participants.length
|
|
664
|
+
? ` · ${document.participants.map((party) => party.name).join(', ')}`
|
|
665
|
+
: ''}
|
|
666
|
+
{document.sections.length
|
|
667
|
+
? ` · ${document.sections.map((section) => section.label).join(', ')}`
|
|
668
|
+
: ''}
|
|
669
|
+
</p>
|
|
670
|
+
{preview(document) ? (
|
|
671
|
+
<p className="mt-1 text-sm text-gray-600">{preview(document)}</p>
|
|
672
|
+
) : null}
|
|
673
|
+
</button>
|
|
674
|
+
|
|
675
|
+
{/* WHERE THE SENTENCE USED TO BE. A row with no captured text said
|
|
676
|
+
"No text was captured for this record." — an explanation
|
|
677
|
+
standing in the place a control belongs, which tells a reader
|
|
678
|
+
what happened and not what they may do. It is a BUTTON now,
|
|
679
|
+
and it is disabled when the action genuinely cannot be
|
|
680
|
+
performed. Quinn, 2026-08-27: "either have a button to pull the
|
|
681
|
+
text, or have it grayed out if we can't do the action. simple
|
|
682
|
+
ui." */}
|
|
683
|
+
{preview(document) ? null : (
|
|
684
|
+
<GetTextButton
|
|
685
|
+
document={document}
|
|
686
|
+
className={cn.button}
|
|
687
|
+
onRequestText={onRequestText}
|
|
688
|
+
/>
|
|
458
689
|
)}
|
|
459
|
-
</
|
|
690
|
+
</div>
|
|
460
691
|
))}
|
|
461
692
|
</div>
|
|
462
693
|
|
|
@@ -484,6 +715,7 @@ export function LibraryPage({
|
|
|
484
715
|
</div>
|
|
485
716
|
) : null}
|
|
486
717
|
</section>
|
|
718
|
+
</div>
|
|
487
719
|
|
|
488
720
|
{selected ? (
|
|
489
721
|
<DocumentDetail
|
|
@@ -508,6 +740,72 @@ export function LibraryPage({
|
|
|
508
740
|
);
|
|
509
741
|
}
|
|
510
742
|
|
|
743
|
+
// ---------------------------------------------------------------------------
|
|
744
|
+
// the row's own action
|
|
745
|
+
// ---------------------------------------------------------------------------
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* "Get text" on a record that has none — ENABLED when the corpus can fetch it,
|
|
749
|
+
* GREYED OUT when it cannot (bd startsim-ei8gm.5).
|
|
750
|
+
*
|
|
751
|
+
* THIS REPLACED A SENTENCE, and the swap is the point. The row used to read
|
|
752
|
+
* "No text was captured for this record." in exactly this slot. That is an
|
|
753
|
+
* explanation where a control belongs: it answers "what happened" when the
|
|
754
|
+
* reader was asking "what can I do", and it is the thing Quinn objected to in
|
|
755
|
+
* those words — "don't say stupid shit like 'no text blah blah'. either have a
|
|
756
|
+
* button to pull the text, or have it grayed out if we can't do the action.
|
|
757
|
+
* simple ui." A disabled control answers the real question in the standard
|
|
758
|
+
* vocabulary of every desktop application ever written.
|
|
759
|
+
*
|
|
760
|
+
* DISABLED IS THE HONEST STATE TODAY, not a placeholder. Content is opt-in and
|
|
761
|
+
* never the default (bd startsim-h6xnu) — the corpus owes what exists, where it
|
|
762
|
+
* lives and findability, and pulling every file's bytes is the expensive,
|
|
763
|
+
* privacy-loaded half that was deliberately not built. The route that would make
|
|
764
|
+
* this live is bd startsim-99g8.2. When it lands, an app passes `onRequestText`
|
|
765
|
+
* and the same control wakes up; nothing here changes.
|
|
766
|
+
*
|
|
767
|
+
* `title` CARRIES THE WHY, and a tooltip on a control is not the sentence that
|
|
768
|
+
* was removed: it is attached to the thing it is about, costs no row space, and
|
|
769
|
+
* a reader who does not care never reads it. `aria-disabled` is not used in its
|
|
770
|
+
* place — a genuinely inert control should not be focusable-and-lying.
|
|
771
|
+
*/
|
|
772
|
+
function GetTextButton({
|
|
773
|
+
document, className, onRequestText,
|
|
774
|
+
}: {
|
|
775
|
+
document: SourceDocument;
|
|
776
|
+
className: string;
|
|
777
|
+
onRequestText?: (document: SourceDocument) => void | Promise<void>;
|
|
778
|
+
}) {
|
|
779
|
+
const [busy, setBusy] = React.useState(false);
|
|
780
|
+
const [failed, setFailed] = React.useState('');
|
|
781
|
+
const can = Boolean(onRequestText);
|
|
782
|
+
|
|
783
|
+
return (
|
|
784
|
+
<button
|
|
785
|
+
type="button"
|
|
786
|
+
className={`${className} shrink-0 self-start`}
|
|
787
|
+
disabled={!can || busy}
|
|
788
|
+
title={
|
|
789
|
+
can
|
|
790
|
+
? `Fetch the text of ${document.title}`
|
|
791
|
+
: 'This workspace does not pull file contents. Nothing has been extracted from this record.'
|
|
792
|
+
}
|
|
793
|
+
onClick={() => {
|
|
794
|
+
if (!onRequestText) return;
|
|
795
|
+
setBusy(true);
|
|
796
|
+
setFailed('');
|
|
797
|
+
void Promise.resolve(onRequestText(document))
|
|
798
|
+
.catch((err: unknown) => setFailed(
|
|
799
|
+
(err as { message?: string })?.message || 'Could not fetch the text.',
|
|
800
|
+
))
|
|
801
|
+
.finally(() => setBusy(false));
|
|
802
|
+
}}
|
|
803
|
+
>
|
|
804
|
+
{busy ? 'Getting…' : failed ? 'Retry' : 'Get text'}
|
|
805
|
+
</button>
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
|
|
511
809
|
// ---------------------------------------------------------------------------
|
|
512
810
|
// freshness
|
|
513
811
|
// ---------------------------------------------------------------------------
|