@startsimpli/integrations 0.2.34 → 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.
@@ -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(filters);
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, filters, scope]);
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={() => { setDraftSearch(''); setFilters({ page: 1, pageSize: size }); }}
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
- <EmptyState hasFilters={Boolean(filters.search || filters.source || filters.partyId || filters.occurredAfter || filters.occurredBefore)} sources={sources} classNames={cn} />
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
- <button
432
- key={document.id}
433
- type="button"
434
- className={cn.row}
435
- aria-expanded={selectedId === document.id}
436
- onClick={() => select(selectedId === document.id ? null : document.id)}
437
- >
438
- <div className="flex flex-wrap items-center gap-2">
439
- {/* The source is a BADGE on every row, never a hidden column:
440
- "the AI learned this from a meeting" and "…from a contract in
441
- Drive" are different claims to whoever reads a draft later. */}
442
- <span className={cn.badge}>{document.sourceLabel || document.source}</span>
443
- <span className="font-medium text-gray-900">{document.title}</span>
444
- </div>
445
- <p className={cn.muted}>
446
- {formatDate(document.occurredAt)}
447
- {document.participants.length
448
- ? ` · ${document.participants.map((party) => party.name).join(', ')}`
449
- : ''}
450
- {document.sections.length
451
- ? ` · ${document.sections.map((section) => section.label).join(', ')}`
452
- : ''}
453
- </p>
454
- {preview(document) ? (
455
- <p className="mt-1 text-sm text-gray-600">{preview(document)}</p>
456
- ) : (
457
- <p className={cn.muted}>No text was captured for this record.</p>
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
- </button>
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
  // ---------------------------------------------------------------------------
@@ -32,6 +32,7 @@
32
32
  */
33
33
  import {
34
34
  DEFAULT_PARTICIPANT_EDGES,
35
+ FOLDER_ROOT,
35
36
  DEFAULT_RUN_KEY,
36
37
  DEFAULT_TYPE_KEYS,
37
38
  MAX_ID_LIST,
@@ -40,11 +41,14 @@ import {
40
41
  indexParticipants,
41
42
  toCommitment,
42
43
  toExtractionRun,
44
+ toFolder,
43
45
  toMoneyEvent,
44
46
  toParty,
45
47
  toSourceDocument,
46
48
  } from './entities';
47
- import type { CorpusTypeKeys, TenantEntity, TenantRelationship } from './entities';
49
+ import type {
50
+ CorpusTypeKeys, TenantEntity, TenantFacetRow, TenantRelationship,
51
+ } from './entities';
48
52
  import {
49
53
  FOCUS_TYPE,
50
54
  IN_FOCUS,
@@ -58,9 +62,11 @@ import type {
58
62
  CommitmentApi,
59
63
  CommitmentQuery,
60
64
  CorpusApi,
65
+ CorpusFolder,
61
66
  CorpusPage,
62
67
  CorpusScope,
63
68
  DocumentQuery,
69
+ FolderQuery,
64
70
  ExtractionRun,
65
71
  ExtractionRunStore,
66
72
  AuthoredItem,
@@ -214,6 +220,17 @@ interface ServerFilterReport {
214
220
  */
215
221
  const TEXT_FILTER_MAX = 500;
216
222
 
223
+ /**
224
+ * The attribute the folder tree is DERIVED from (start-simpli-api#369 ships it
225
+ * as a source-agnostic container: `/Contracts`, never `/Contracts/SOW.pdf`).
226
+ *
227
+ * One name, in one place, because the facet read enumerates it and the document
228
+ * filter narrows on it — and a tree whose nodes came from one attribute while
229
+ * its file lists filtered on another would draw folders that open onto nothing.
230
+ */
231
+ const PATH_ATTRIBUTE = 'path';
232
+
233
+
217
234
  function commitmentParams(query: CommitmentQuery, type: string): Record<string, unknown> {
218
235
  const statuses = Array.isArray(query.status) ? query.status : query.status ? [query.status] : [];
219
236
  const out: Record<string, unknown> = {
@@ -270,6 +287,8 @@ const DOCUMENT_FILTER_PARAMS: Array<[keyof DocumentQuery, string[]]> = [
270
287
  ['partyId', ['party', 'related_to']],
271
288
  ['occurredAfter', ['occurred_after', 'attr.occurred_at__gte']],
272
289
  ['occurredBefore', ['occurred_before', 'attr.occurred_at__lte']],
290
+ ['folder', ['attr.path']],
291
+ ['underFolder', ['attr.path__under']],
273
292
  ];
274
293
 
275
294
  function reportedDocumentFilters(query: DocumentQuery, applied: string[]): string[] {
@@ -351,6 +370,39 @@ function params(query: DocumentQuery, type: string) {
351
370
  if (query.partyId) out.party = query.partyId;
352
371
  if (query.occurredAfter) out.occurred_after = query.occurredAfter;
353
372
  if (query.occurredBefore) out.occurred_before = query.occurredBefore;
373
+ // THE FOLDER BOUNDS, and only ever one of them. `attr.path` is the shelf and
374
+ // `attr.path__under` is the subtree; sending both would AND an exact match
375
+ // with its own subtree and answer with the shelf, which is the exact-match
376
+ // query wearing a second parameter. The composer chooses; this just refuses to
377
+ // send a contradiction.
378
+ if (query.underFolder) out['attr.path__under'] = query.underFolder;
379
+ else if (query.folder) out['attr.path'] = query.folder;
380
+ return out;
381
+ }
382
+
383
+ /**
384
+ * `GET /api/v1/entities/facets/` — its OWN parameter builder, and that is not
385
+ * tidiness (bd startsim-ei8gm.5).
386
+ *
387
+ * THE FACET ACTION REFUSES UNKNOWN PARAMETERS. `_Scoped.initial()` screens it
388
+ * against `_FACET_FILTERS` before any handler runs — the tenant added
389
+ * `extra_list_parameters` precisely so a new list-shaped route could not inherit
390
+ * the old fail-open posture — so handing it `params(query, type)` would 400 the
391
+ * whole read on `occurred_after`, `party` or `source` the moment a filter was
392
+ * set. Its vocabulary is the entity filters PLUS `attr` and `under`, and this
393
+ * sends exactly `type`, `attr`, `under` and the page.
394
+ */
395
+ function facetParams(query: FolderQuery, type: string) {
396
+ const out: Record<string, unknown> = {
397
+ type,
398
+ attr: PATH_ATTRIBUTE,
399
+ // The FULL path the server handed back, verbatim. `/` is the root and means
400
+ // the whole corpus — `subtree_bound()` makes the root's descendant prefix
401
+ // `/`, which every stored path begins with.
402
+ under: query.under || FOLDER_ROOT,
403
+ page: query.page ?? 1,
404
+ };
405
+ if (query.pageSize) out.page_size = query.pageSize;
354
406
  return out;
355
407
  }
356
408
 
@@ -359,9 +411,9 @@ function appliedFilters(
359
411
  query: DocumentQuery,
360
412
  supported: Array<string>,
361
413
  ): string[] {
362
- const requested = (['search', 'source', 'partyId', 'occurredAfter', 'occurredBefore'] as const).filter(
363
- (key) => Boolean(query[key]),
364
- );
414
+ const requested = ([
415
+ 'search', 'source', 'partyId', 'occurredAfter', 'occurredBefore', 'folder', 'underFolder',
416
+ ] as const).filter((key) => Boolean(query[key]));
365
417
  return requested.filter((key) => supported.includes(key));
366
418
  }
367
419
 
@@ -683,6 +735,38 @@ export function createCorpusApi(
683
735
  return page<Party>(types.party, query, (entity) => toParty(entity, scope));
684
736
  },
685
737
 
738
+ /**
739
+ * ONE LEVEL of the folder tree (bd startsim-ei8gm.5).
740
+ *
741
+ * `GET /api/v1/entities/facets/?type=source_document&attr=path&under=/Contracts`
742
+ * -> the CHILD FOLDERS of `/Contracts`, paginated, each row already carrying
743
+ * its descendant count and whether anything sits below it.
744
+ *
745
+ * THE GATE IS THE SERVER'S AND IT STAYS THERE. The tenant answers this out
746
+ * of `EntityViewSet.get_queryset()` itself — the same type gate, row gate,
747
+ * field gate and filters the row read composes — so the folders a member is
748
+ * shown are the folders their own documents are in, and nothing else. On a
749
+ * tenant whose `source_document` is `row_scope: personal` that is the whole
750
+ * of the protection: a folder name leaks plenty on its own
751
+ * (`/Legal/Terminations`, `/Acquisition-TargetCo`). Which is why this method
752
+ * asks for ONE `under=` and returns exactly what came back for it. It does
753
+ * not cache across containers, does not merge two responses, and does not
754
+ * infer a parent from a child's path — any of which would rebuild the leak
755
+ * client-side out of rows the server was careful about.
756
+ */
757
+ async listFolders(query: FolderQuery): Promise<CorpusPage<CorpusFolder>> {
758
+ const response = await http.get<DrfPage<TenantFacetRow> & ServerFilterReport>(
759
+ `${entities}/facets`,
760
+ { params: facetParams(query, types.document) },
761
+ );
762
+ return {
763
+ count: response.count ?? response.results?.length ?? 0,
764
+ next: response.next ?? null,
765
+ previous: response.previous ?? null,
766
+ results: (response.results ?? []).map(toFolder),
767
+ };
768
+ },
769
+
686
770
  async listMoneyEvents(query = {}) {
687
771
  const events = await page<TenantEntity>(types.moneyEvent, query, (entity) => entity);
688
772
  const byRecord = await participantsFor(events.results.map((entity) => entity.id));