@unson/brainbase-mcp 0.41.11 → 0.41.13

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.
@@ -2,32 +2,38 @@
2
2
  * 「情報と関係」: what the local Graph holds, how records are connected, and
3
3
  * where to correct a mistake so the next MCP use sees the fix.
4
4
  *
5
- * Built on the organization edition's screen pattern (workspace kit): the
6
- * workspace has the page head, the source notice, the search, the results
7
- * ledger and the kinds of information as two ledgers; the right rail has the
8
- * selected record — its outline, the relations going out and coming in, and
9
- * the corrections.
5
+ * 「調べる」 (premise ledger D14, story-graph-registry-immediate-feedback-v1):
6
+ * the list has the search, the kinds as tabs with counts, 詳しい条件 (an
7
+ * earlier day), the conditions in force as removable chips and the results.
8
+ * Everything applies as it changes. A record opens as its own screen with an
9
+ * address the host keeps (`onRouteChange`): what it is, its connections read
10
+ * as sentences and grouped by the other record's kind, 根拠と履歴 (folded:
11
+ * source, period, corrections, ids) and 直す, which first shows what a fix
12
+ * reaches. The right rail stays empty.
13
+ *
14
+ * This screen answers what the Graph knows (ledger D14). The kinds of
15
+ * information and relations (the ontology) are definitions, drawn by
16
+ * `graph-ontology-view.js` under the host's settings; the owner's own snapshot
17
+ * is drawn by `createOwnShareView` (`graph-own-share.js`) where the owner takes
18
+ * records in. Neither is drawn here.
10
19
  *
11
20
  * Reads `GET {base}/search`, `GET {base}/entities/:id` and `GET {base}/ontology`
12
- * and corrects through `POST {base}/corrections` (see `graph-view-shared.js`).
13
- * The kinds of information and relations are shown read-only; there is no
14
- * change proposal here. The part reads only the Graph routes the host serves
15
- * at its base path (the local Graph on the OSS host). The host injects the
16
- * fetcher, base path, launch token, right rail and page context; this module
17
- * keeps no global state. Another host composes it unchanged and adds only its
18
- * own controls through the options of `createGraphRegistryView`, including the
19
- * owner's own share in a separate section (`graph-own-share.js`).
21
+ * (for the kinds and their counts) and corrects through `POST {base}/corrections`
22
+ * (see `graph-view-shared.js`). The host injects the fetcher, base path, launch
23
+ * token, page context and the record's address; this module keeps no global
24
+ * state. Another host composes it unchanged and adds only its own controls
25
+ * through the options of `createGraphRegistryView`.
20
26
  */
21
27
 
22
28
  import {
23
29
  activityBadge,
24
- badge,
25
30
  button,
26
31
  createGraphClient,
27
32
  createGraphCorrection,
28
33
  createGraphLayout,
29
34
  dayToRfc3339,
30
35
  focusLedgerRow,
36
+ formatDay,
31
37
  getDocument,
32
38
  graphCommandList,
33
39
  graphCorrectionScope,
@@ -38,68 +44,77 @@ import {
38
44
  hostSourceNotice,
39
45
  isEdgeView,
40
46
  isEntityView,
41
- isRecord,
42
47
  makeElement,
43
48
  newRelationOptions,
44
49
  ownLabel,
45
50
  readOnlyGraphClient,
46
51
  recordDetails,
47
52
  relationLabel,
48
- renderCorrectionHistory,
49
- renderGraphIssues,
53
+ renderEvidenceAndHistory,
50
54
  renderProvenance,
51
55
  shellArg,
52
56
  textOrNull,
53
57
  typeLabel,
54
58
  validityLabel,
55
59
  validityText,
56
- GRAPH_CORRECTION_FIELDS,
57
- GRAPH_ENTITY_TYPE_LABELS,
58
- GRAPH_ENTITY_TYPE_MEANINGS,
59
60
  GRAPH_RELATION_LABELS,
60
61
  graphHostEmptyNotice,
61
62
  } from './graph-view-shared.js';
62
- import { graphOwnShareSource, ownEntityDetail, ownShareSection, ownShareStateNotice } from './graph-own-share.js';
63
+ import { normalizeOntology } from './graph-ontology-view.js';
63
64
  import {
64
65
  workspaceActions,
65
66
  workspaceButton,
66
67
  workspaceDefinition,
67
- workspaceDetailEmpty,
68
68
  workspaceHostNotice,
69
69
  workspaceLedger,
70
70
  workspaceNotice,
71
71
  workspacePageHeader,
72
- workspaceRailBlock,
73
- workspaceRailHead,
74
- workspaceSectionTitle,
75
72
  } from './workspace-kit.js';
76
73
 
77
- export const GRAPH_REGISTRY_VIEW_CONTRACT_VERSION = 'brainbase.graph-registry-view.v2';
74
+ export const GRAPH_REGISTRY_VIEW_CONTRACT_VERSION = 'brainbase.graph-registry-view.v3';
78
75
  export const GRAPH_SEARCH_LIMIT = 50;
79
- export const GRAPH_ENTITY_LEDGER_COLUMNS = Object.freeze(['名前', '種類', '要約', '有効期間']);
80
- export const GRAPH_RELATION_TYPE_LEDGER_COLUMNS = Object.freeze(['関係', '起点 → 終点', '意味', '件数']);
81
- export const GRAPH_ENTITY_TYPE_LEDGER_COLUMNS = Object.freeze(['種類', '意味', '件数']);
76
+ export const GRAPH_ENTITY_LEDGER_COLUMNS = Object.freeze(['名前', '種類', '説明', '最終更新']);
82
77
 
83
78
  const INVALID = Object.freeze({ state: 'invalid', reason: '応答の形式が不正です' });
84
- const ALL_TYPES = Object.freeze(['', 'すべての種類']);
85
- const TYPE_FILTERS = Object.freeze([ALL_TYPES, ...Object.entries(GRAPH_ENTITY_TYPE_LABELS)]);
79
+ const DETAIL_ID = 'bb-gr-detail';
86
80
 
87
81
  /**
88
- * The kinds the search can filter by: the entity types the host's `/ontology`
89
- * answer lists (an unknown type shows its raw id), or the local Graph's four
90
- * kinds until that answer has been read.
82
+ * People, companies and projects lead the tabs, under the names owners use for
83
+ * them (ledger D14). Every other kind uses the shared plain name; a kind
84
+ * without one is internal and is never named.
91
85
  */
92
- function graphTypeFilters(ontologyState) {
93
- const types = ontologyState?.state === 'ok' && Array.isArray(ontologyState.payload?.entityTypes) ? ontologyState.payload.entityTypes : [];
94
- if (types.length === 0) return TYPE_FILTERS;
95
- return [ALL_TYPES, ...types.map((item) => [item.id, typeLabel(item.id)])];
86
+ const LEADING_KINDS = Object.freeze({ person: '人', org: '会社', project: 'プロジェクト' });
87
+ const DEFAULT_KIND_TABS = Object.freeze(['person', 'org', 'project', 'decision']);
88
+
89
+ function kindLabel(type) {
90
+ const leading = ownLabel(LEADING_KINDS, type);
91
+ if (leading) return leading;
92
+ const shared = typeLabel(type);
93
+ return typeof type === 'string' && shared !== type ? shared : 'その他の記録';
94
+ }
95
+
96
+ function hasKindLabel(type) {
97
+ return kindLabel(type) !== 'その他の記録';
98
+ }
99
+
100
+ /** Leading kinds first, the rest in the order given. */
101
+ function kindRank(type) {
102
+ const index = Object.keys(LEADING_KINDS).indexOf(type);
103
+ return index < 0 ? Object.keys(LEADING_KINDS).length : index;
96
104
  }
97
105
 
98
- /** A type's meaning: the plain Japanese one for a local kind, otherwise what the host's answer says. */
99
- function typeMeaning(item) {
100
- return ownLabel(GRAPH_ENTITY_TYPE_MEANINGS, item.id) ?? textOrNull(item.meaning) ?? '';
106
+ /** How a relation reads from this record's side; a relation without a Japanese name only says they connect. */
107
+ function relationPhrase(edge) {
108
+ const labels = ownLabel(GRAPH_RELATION_LABELS, edge.relation);
109
+ if (!labels) return 'とつながる';
110
+ return edge.direction === 'outgoing' ? labels.outgoing : labels.incoming;
101
111
  }
102
112
 
113
+ /** The day a record last changed, when the host says so. */
114
+ function updatedDay(entity) {
115
+ const value = textOrNull(entity?.updatedAt) ?? textOrNull(entity?.updated_at);
116
+ return value ? (formatDay(value) ?? value.slice(0, 10)) : null;
117
+ }
103
118
  /** Results are `ok` when well formed; no match is shown only when the whole Graph was searched. */
104
119
  export function normalizeSearch(result) {
105
120
  if (result?.state !== 'ok') return result ?? INVALID;
@@ -227,33 +242,7 @@ function relationAvailability(payload, direction) {
227
242
  return { state: 'confirmed_empty' };
228
243
  }
229
244
 
230
- // A host that could not finish counting sends null (with countStatus
231
- // 'unknown', or 'at_least' plus atLeast) rather than a number it cannot vouch for.
232
- function isCount(value) {
233
- return value === null || Number.isInteger(value);
234
- }
235
-
236
- function countText(item, key = 'count') {
237
- if (Number.isInteger(item[key])) return `${item[key]}件`;
238
- if (key === 'count' && item.countStatus === 'at_least' && Number.isInteger(item.atLeast)) return `${item.atLeast}件以上`;
239
- return '件数は未確認';
240
- }
241
-
242
- export function normalizeOntology(result) {
243
- if (result?.state !== 'ok') return result ?? INVALID;
244
- const payload = result.payload;
245
- const entityTypesOk = Array.isArray(payload.entityTypes) && payload.entityTypes.every((item) => isRecord(item)
246
- && typeof item.id === 'string' && isCount(item.count));
247
- const relationsOk = Array.isArray(payload.relations) && payload.relations.every((item) => isRecord(item)
248
- && typeof item.id === 'string' && typeof item.from === 'string' && typeof item.to === 'string'
249
- && isCount(item.count) && isCount(item.activeCount));
250
- if (!entityTypesOk || !relationsOk || !isRecord(payload.ontology)) return INVALID;
251
- return { state: 'ok', payload };
252
- }
253
-
254
- function fieldNames(fields) {
255
- return (Array.isArray(fields) ? fields : []).map((field) => GRAPH_CORRECTION_FIELDS[field]?.label ?? field).join('・');
256
- }
245
+ export { normalizeOntology };
257
246
 
258
247
  function textCell(value, fallback) {
259
248
  return textOrNull(value) ?? { text: fallback, className: 'is-unresolved' };
@@ -264,7 +253,7 @@ function textCell(value, fallback) {
264
253
  *
265
254
  * @param {object} options
266
255
  * @param {Element} options.root Where the workspace is drawn.
267
- * @param {Element} [options.rail] The host's right rail; without it the rail content follows the workspace inside `root`.
256
+ * @param {Element} [options.rail] The host's right rail. It is left empty: the record opens in the workspace.
268
257
  * @param {{ crumbs?: string[], source?: string }} [options.page] Breadcrumb and source label of the page head.
269
258
  * @param {Document} [options.document] The document to build elements with (default: the global one).
270
259
  * @param {Function} [options.fetcher] `fetch`-compatible function for the Graph routes.
@@ -272,6 +261,12 @@ function textCell(value, fallback) {
272
261
  * @param {string} [options.token] Launch token sent with a correction.
273
262
  * @param {string} [options.tokenHeader] Header name for the token.
274
263
  * @param {boolean} [options.autoLoad=true] Runs the empty search and reads the kinds on mount.
264
+ * @param {number} [options.searchDelayMs=300] How long the name field waits after the last keystroke
265
+ * before it searches (never while a Japanese conversion is still being composed).
266
+ * @param {(entityId: string | null, options: { back: boolean }) => void} [options.onRouteChange]
267
+ * Called when the owner opens a record (its id) or goes back to the list (null, `back: true`), so the
268
+ * host can give the record's screen an address. When that address changes from outside (the
269
+ * browser's back), the host calls `openEntity(id)` or `closeEntity()`; neither reports back.
275
270
  * @param {string} [options.initialEntityId] Opens this exact entity after the initial Graph read.
276
271
  * @param {() => Date} [options.now] The owner's clock.
277
272
  * @param {boolean} [options.canCorrect=true] When false, no correction control is drawn anywhere
@@ -284,26 +279,23 @@ function textCell(value, fallback) {
284
279
  * such a correction before sending it. Without it every correction is allowed.
285
280
  * @param {string | ((record: { id: string, type: string | null, metadata: object } | null) => string | Element | null)} [options.readOnlyNote]
286
281
  * With `canCorrect: false`, or where `correctionScope` withholds a
287
- * control of the selected record, a note shown in the rail where the correction buttons would be.
288
- * Without it the rail says nothing about corrections.
282
+ * control of the open record, a note shown under 直す where the correction buttons would be.
283
+ * Without it nothing is said about corrections.
289
284
  * @param {{ label?: string, text: string | Element }} [options.emptyNotice] Replaces the 未登録 notice
290
285
  * and its `brainbase onboard:*` commands when the host's Graph has nothing registered.
291
- * @param {{ label?: string, text: string | Element }} [options.sourceNotice] Replaces the default 出典 notice.
292
- * @param {{ basePath: string, fetcher?: Function, label?: string }} [options.ownShare]
293
- * The owner's own snapshot the host keeps for them (`graph-own-share.js`): the same search runs on
294
- * `{basePath}/search` and its results are shown read-only in a separate section after the host's
295
- * results, labelled 「引き継いだ自分の分(組織には未反映)」 unless `label` says otherwise; a record
296
- * opens below them from `{basePath}/entities/:id`, with a decision's judgment record. They never
297
- * enter the host's results or rail, and a failed read shows only in that section. The host's
298
- * results are then captioned as the organization's. Without it nothing of the kind is drawn or read.
286
+ * @param {{ label?: string, text: string | Element }} [options.sourceNotice] Replaces the default 出典 notice
287
+ * inside the open record's 根拠と履歴.
288
+ * @param {unknown} [options.ownShare] Ignored. The owner's own snapshot is not part of this screen
289
+ * (ledger D14); a host mounts `createOwnShareView` from `graph-own-share.js` where the owner takes records in.
299
290
  *
300
- * The type filter lists the entity types of the `/ontology` answer once it has
301
- * been read (the four local kinds until then). A type without a plain label
302
- * shows its raw id in the filter, the results ledger and the rail.
291
+ * The kind tabs list the entity types of the `/ontology` answer that have a
292
+ * plain name, people, companies and projects first (the four local kinds until
293
+ * it has been read). A type without one is never named: it is listed under
294
+ * すべて as その他の記録.
303
295
  *
304
296
  * @returns The controller: `state`, `correction`, `render()`, `load()`, `search()`,
305
- * `refreshResults()`, `loadEntity(id)`, `openEntity(id)`, `back()`, `loadOntology()`, and with
306
- * `ownShare` `searchOwn()` and `openOwnEntity(id)`.
297
+ * `clearFilter(key)`, `clearFilters()`, `refreshResults()`, `loadEntity(id)`, `navigate(id)`,
298
+ * `openEntity(id)`, `closeEntity()`, `backToList()`, `back()`, `loadOntology()`.
307
299
  */
308
300
  export function createGraphRegistryView({
309
301
  root,
@@ -315,19 +307,19 @@ export function createGraphRegistryView({
315
307
  token,
316
308
  tokenHeader,
317
309
  autoLoad = true,
310
+ searchDelayMs = 300,
318
311
  initialEntityId,
312
+ onRouteChange,
319
313
  now = () => new Date(),
320
314
  canCorrect = true,
321
315
  correctionScope,
322
316
  readOnlyNote,
323
317
  sourceNotice,
324
318
  emptyNotice,
325
- ownShare,
326
319
  } = {}) {
327
320
  if (!root) throw new TypeError('root is required');
328
321
  const doc = getDocument(explicitDocument);
329
322
  const graphClient = createGraphClient({ fetcher, basePath, token, ...(tokenHeader ? { tokenHeader } : {}) });
330
- const own = graphOwnShareSource(ownShare, { fetcher });
331
323
  const writable = canCorrect !== false;
332
324
  const client = writable ? graphClient : readOnlyGraphClient(graphClient);
333
325
  const scope = graphCorrectionScope(correctionScope);
@@ -346,16 +338,25 @@ export function createGraphRegistryView({
346
338
  applied: { q: '', type: '', asOfDay: '' },
347
339
  queryMessage: null,
348
340
  search: { state: 'loading' },
349
- // `detail` is the record in the rail: { id, state, payload?, pending? }.
341
+ // A new search is being read while the last results stay on screen.
342
+ searching: false,
343
+ // 詳しい条件 stays open across redraws once the owner opened it.
344
+ advancedOpen: false,
345
+ // `detail` is the open record: { id, state, payload?, pending? }.
350
346
  detail: null,
347
+ // The row of the results the open record hangs from (the record the owner clicked).
348
+ anchor: null,
351
349
  trail: [],
352
350
  ontology: { state: 'loading' },
353
351
  // Where the open correction shows: a relation id (inside that relation) or anything else (below the record actions).
354
352
  panelAt: null,
355
- // The owner's own share: the same search on its own routes, and its own open record.
356
- own: own ? { search: { state: 'loading' }, detail: null } : null,
357
353
  };
358
354
  let focusRow = null;
355
+ // The control to keep focused across a redraw, and the open record to bring into view once.
356
+ let revealDetail = false;
357
+ let composing = false;
358
+ let renderDeferred = false;
359
+ let searchTimer = null;
359
360
 
360
361
  const correction = createGraphCorrection({
361
362
  client,
@@ -386,53 +387,131 @@ export function createGraphRegistryView({
386
387
  // -------------------------------------------------------------------------
387
388
  // Workspace
388
389
 
390
+ function cancelScheduledSearch() {
391
+ if (searchTimer !== null) clearTimeout(searchTimer);
392
+ searchTimer = null;
393
+ }
394
+
395
+ /** The name field searches once typing pauses; nothing is searched mid-conversion. */
396
+ function scheduleSearch() {
397
+ cancelScheduledSearch();
398
+ searchTimer = setTimeout(() => {
399
+ searchTimer = null;
400
+ void controller.search();
401
+ }, Math.max(0, Number(searchDelayMs) || 0));
402
+ }
403
+
389
404
  function renderSearchForm() {
390
405
  const section = makeElement(doc, 'section', { className: 'bb-gr-search-section', attrs: { 'aria-label': '検索' } });
391
406
  const form = makeElement(doc, 'form', { className: 'bb-graph-form bb-gr-search', attrs: { role: 'search', novalidate: true } });
407
+ // Enter searches at once; every other change searches by itself.
392
408
  form.addEventListener('submit', (event) => {
393
409
  event?.preventDefault?.();
410
+ cancelScheduledSearch();
394
411
  return controller.search();
395
412
  });
396
- const field = (id, label, control) => {
397
- const wrap = makeElement(doc, 'div', { className: 'bb-graph-field' });
413
+ const field = (id, label, control, className = '') => {
414
+ const wrap = makeElement(doc, 'div', { className: `bb-graph-field${className ? ` ${className}` : ''}` });
398
415
  wrap.append(makeElement(doc, 'label', { text: label, attrs: { for: id } }), control);
399
416
  return wrap;
400
417
  };
401
- const bind = (control, key) => {
402
- const update = (event) => { state.query[key] = event?.target?.value ?? control.value; };
403
- control.addEventListener('input', update);
404
- control.addEventListener('change', update);
405
- };
406
- const q = makeElement(doc, 'input', { attrs: { id: 'bb-gr-q', name: 'q', type: 'search', maxlength: 200, placeholder: '例: 田中' } });
418
+ const q = makeElement(doc, 'input', { attrs: { id: 'bb-gr-q', name: 'q', type: 'search', maxlength: 200, placeholder: '名前・別名で探す(例: 田中)', autocomplete: 'off', 'data-focus-key': 'q' } });
407
419
  q.value = state.query.q;
408
- bind(q, 'q');
409
- const type = makeElement(doc, 'select', { attrs: { id: 'bb-gr-type', name: 'type' } });
410
- for (const [value, label] of graphTypeFilters(state.ontology)) {
411
- const option = makeElement(doc, 'option', { text: label, attrs: { value } });
412
- if (value === state.query.type) {
413
- option.setAttribute('selected', 'selected');
414
- option.selected = true;
415
- }
416
- type.append(option);
417
- }
418
- type.value = state.query.type;
419
- bind(type, 'type');
420
- const asOf = makeElement(doc, 'input', { attrs: { id: 'bb-gr-as-of', name: 'as_of', type: 'date' } });
420
+ q.addEventListener('input', (event) => {
421
+ state.query.q = event?.target?.value ?? q.value;
422
+ if (event?.isComposing || composing) return;
423
+ scheduleSearch();
424
+ });
425
+ q.addEventListener('compositionstart', () => { composing = true; });
426
+ q.addEventListener('compositionend', (event) => {
427
+ composing = false;
428
+ state.query.q = event?.target?.value ?? q.value;
429
+ scheduleSearch();
430
+ if (renderDeferred) controller.render();
431
+ });
432
+ form.append(field('bb-gr-q', '名前・別名', q));
433
+ // Looking at another day is rare: it stays under 詳しい条件.
434
+ const asOf = makeElement(doc, 'input', { attrs: { id: 'bb-gr-as-of', name: 'as_of', type: 'date', 'data-focus-key': 'as_of' } });
421
435
  asOf.value = state.query.asOfDay;
422
- bind(asOf, 'asOfDay');
423
- form.append(
424
- field('bb-gr-q', '名前・別名', q),
425
- field('bb-gr-type', '種類', type),
426
- field('bb-gr-as-of', '有効時点(空なら今)', asOf),
427
- makeElement(doc, 'button', { className: 'bb-ws-button is-primary bb-gr-search-submit', text: '探す', attrs: { type: 'submit' } }),
436
+ asOf.addEventListener('change', (event) => {
437
+ state.query.asOfDay = event?.target?.value ?? asOf.value;
438
+ cancelScheduledSearch();
439
+ void controller.search();
440
+ });
441
+ const advanced = makeElement(doc, 'details', { className: 'bb-gr-advanced' });
442
+ if (state.advancedOpen || state.query.asOfDay) advanced.setAttribute('open', '');
443
+ advanced.addEventListener('toggle', () => { state.advancedOpen = Boolean(advanced.open); });
444
+ advanced.append(
445
+ makeElement(doc, 'summary', { text: '詳しい条件' }),
446
+ field('bb-gr-as-of', '過去のある日の状態で見る', asOf, 'bb-gr-as-of-field'),
447
+ makeElement(doc, 'p', { className: 'bb-gr-hint', text: '日付を選ぶと、その日に有効だった記録だけを、その日の状態で見ます。空なら今日です。' }),
428
448
  );
429
- section.append(form);
449
+ section.append(form, renderTypeTabs(), advanced);
430
450
  if (state.queryMessage) section.append(workspaceNotice(doc, { label: '入力', text: state.queryMessage, tone: 'danger' }));
451
+ const chips = renderActiveFilters();
452
+ if (chips) section.append(chips);
431
453
  return section;
432
454
  }
433
455
 
456
+ /**
457
+ * The kinds as tabs with their counts: people, companies and projects first.
458
+ * Only kinds with a plain name become tabs (an internal type id is never
459
+ * shown); an unread or unfinished count is left off rather than shown as 0.
460
+ */
461
+ function renderTypeTabs() {
462
+ const read = state.ontology.state === 'ok' && Array.isArray(state.ontology.payload?.entityTypes) ? state.ontology.payload.entityTypes : null;
463
+ const counts = new Map((read ?? []).map((item) => [item.id, item]));
464
+ const selected = state.query.type;
465
+ const ids = (read ? read.map((item) => item.id).filter(hasKindLabel) : DEFAULT_KIND_TABS)
466
+ .map((id, index) => ({ id, index }))
467
+ .sort((left, right) => kindRank(left.id) - kindRank(right.id) || left.index - right.index)
468
+ .map(({ id }) => id)
469
+ .filter((id) => id === selected || counts.get(id)?.count !== 0);
470
+ const tabs = makeElement(doc, 'div', { className: 'bb-gr-tabs', attrs: { role: 'tablist', 'aria-label': '種類' } });
471
+ for (const id of ['', ...ids]) {
472
+ const item = counts.get(id);
473
+ const count = Number.isInteger(item?.count) ? String(item.count)
474
+ : item?.countStatus === 'at_least' && Number.isInteger(item.atLeast) ? `${item.atLeast}+` : null;
475
+ const tab = makeElement(doc, 'button', {
476
+ className: `bb-gr-tab${id === selected ? ' is-selected' : ''}`,
477
+ attrs: { type: 'button', role: 'tab', 'aria-selected': id === selected ? 'true' : 'false', 'data-type': id, 'data-focus-key': `type:${id}` },
478
+ });
479
+ tab.append(makeElement(doc, 'span', { text: id ? kindLabel(id) : 'すべて' }));
480
+ if (count) tab.append(makeElement(doc, 'span', { className: 'bb-gr-tab-count', text: count }));
481
+ tab.addEventListener('click', () => {
482
+ if (state.query.type === id) return;
483
+ state.query.type = id;
484
+ cancelScheduledSearch();
485
+ void controller.search();
486
+ });
487
+ tabs.append(tab);
488
+ }
489
+ return tabs;
490
+ }
491
+
492
+ /** The conditions the results were read with, each removable, and すべて外す. */
493
+ function renderActiveFilters() {
494
+ const applied = state.applied;
495
+ const items = [
496
+ applied.q.trim() ? ['q', `名前・別名: ${applied.q.trim()}`] : null,
497
+ applied.type ? ['type', `種類: ${kindLabel(applied.type)}`] : null,
498
+ applied.asOfDay ? ['asOfDay', `${applied.asOfDay} の状態で見ています`] : null,
499
+ ].filter(Boolean);
500
+ if (items.length === 0) return null;
501
+ const bar = makeElement(doc, 'div', { className: 'bb-gr-filters', attrs: { role: 'group', 'aria-label': '今の絞り込み' } });
502
+ bar.append(makeElement(doc, 'span', { className: 'bb-gr-filters-label', text: '絞り込み中' }));
503
+ for (const [key, text] of items) {
504
+ const chip = makeElement(doc, 'button', { className: 'bb-gr-filter-chip', attrs: { type: 'button', 'aria-label': `「${text}」を外す` } });
505
+ chip.append(makeElement(doc, 'span', { text }), makeElement(doc, 'span', { className: 'bb-gr-filter-chip-x', text: '×', attrs: { 'aria-hidden': 'true' } }));
506
+ chip.addEventListener('click', () => controller.clearFilter(key));
507
+ bar.append(chip);
508
+ }
509
+ bar.append(button(doc, 'すべて外す', () => controller.clearFilters(), { className: 'bb-graph-link bb-gr-filters-clear' }));
510
+ return bar;
511
+ }
512
+
434
513
  function renderResults() {
435
- const section = makeElement(doc, 'section', { className: 'bb-gr-results', attrs: { 'aria-label': '検索結果' } });
514
+ const section = makeElement(doc, 'section', { className: 'bb-gr-results', attrs: { 'aria-label': '検索結果', 'aria-busy': state.searching ? 'true' : 'false' } });
436
515
  const stateNotice = graphStateNotice(doc, state.search, { onRetry: () => controller.search(), loadingText: '記録を探しています。' });
437
516
  if (stateNotice) {
438
517
  section.append(stateNotice);
@@ -459,27 +538,26 @@ export function createGraphRegistryView({
459
538
  const asOfDay = asOf ? state.applied.asOfDay || String(asOf).slice(0, 10) : null;
460
539
  section.append(makeElement(doc, 'p', {
461
540
  className: 'bb-gr-caption',
462
- // Next to the owner's own share, these results are named as the organization's.
463
- text: `${own ? '組織のGraphの' : ''}${Number.isInteger(payload.total) ? `検索結果 ${payload.total}件` : `検索結果 ${payload.results.length}件を表示(全体の件数は未確認)`}${asOfDay ? `(${asOfDay} の時点で有効なもの)` : ''}`,
541
+ attrs: { role: 'status' },
542
+ text: `${Number.isInteger(payload.total) ? `検索結果 ${payload.total}件` : `検索結果 ${payload.results.length}件を表示(全体の件数は未確認)`}${asOfDay ? `(${asOfDay} の状態)` : ''}${state.searching ? '。新しい条件で探しています…' : ''}`,
464
543
  }));
544
+ // 最終更新 is a column only when the host says when records changed; it is never filled with guesses.
545
+ const updated = payload.results.some((entity) => updatedDay(entity));
465
546
  section.append(workspaceLedger(doc, {
466
- className: 'bb-graph-entity-ledger',
547
+ className: `bb-graph-entity-ledger${updated ? ' has-updated' : ''}`,
467
548
  ariaLabel: '検索結果の一覧',
468
- columns: GRAPH_ENTITY_LEDGER_COLUMNS,
469
- empty: '条件に合う記録はありません。名前や別名、種類、有効時点を変えて探してください。',
549
+ columns: updated ? GRAPH_ENTITY_LEDGER_COLUMNS : GRAPH_ENTITY_LEDGER_COLUMNS.slice(0, 3),
550
+ empty: '条件に合う記録はありません。名前・別名や種類、日付の条件を変えるか、上の「すべて外す」で条件を外してください。',
470
551
  rows: payload.results.map((entity) => ({
471
552
  key: entity.id,
472
- selected: entity.id === state.detail?.id,
473
553
  className: entity.active ? '' : 'is-ended',
474
- onSelect: (id) => {
475
- focusRow = id;
476
- void controller.openEntity(id);
477
- },
554
+ label: `${entity.name}(${kindLabel(entity.type)})を開く`,
555
+ onSelect: (id) => { void controller.navigate(id); },
478
556
  cells: [
479
- { primary: entity.name, secondary: entity.id },
480
- typeLabel(entity.type),
481
- textCell(entity.summary, '要約なし'),
482
- validityLabel(entity, asOf ?? now().toISOString()),
557
+ entity.active ? { primary: entity.name } : { primary: entity.name, secondary: validityLabel(entity, asOf ?? now().toISOString()) },
558
+ kindLabel(entity.type),
559
+ textCell(entity.summary, '説明の記録なし'),
560
+ ...(updated ? [updatedDay(entity) ?? { text: '未確認', className: 'is-unresolved' }] : []),
483
561
  ],
484
562
  })),
485
563
  }));
@@ -491,83 +569,23 @@ export function createGraphRegistryView({
491
569
  return section;
492
570
  }
493
571
 
494
- function renderOntology() {
495
- const section = makeElement(doc, 'section', { className: 'bb-gr-ontology', attrs: { 'aria-label': '情報の種類とつながり方' } });
496
- section.append(workspaceSectionTitle(doc, {
497
- title: '情報の種類とつながり方',
498
- lead: 'Graphに登録できる情報の種類と、種類どうしのつながり方です。この欄は表示だけです。',
499
- }));
500
- const stateNotice = graphStateNotice(doc, state.ontology, { onRetry: () => controller.loadOntology(), loadingText: '情報の種類を読み込んでいます。' });
501
- if (stateNotice) {
502
- section.append(stateNotice);
503
- return section;
504
- }
505
- const { payload } = state.ontology;
506
- const ontology = payload.ontology;
507
- const version = textOrNull(ontology.version);
508
- const currentVersion = textOrNull(ontology.currentVersion);
509
- const freshness = ontology.upToDate === false && currentVersion
510
- ? `最新ではありません。最新は ${currentVersion}`
511
- : ontology.upToDate === true && version ? '最新' : '最新性は未確認';
512
- section.append(makeElement(doc, 'p', {
513
- className: 'bb-gr-caption',
514
- text: `定義の版 ${version ?? '未確認'}(${freshness})`,
515
- }));
516
- section.append(makeElement(doc, 'h3', { className: 'bb-gr-subheading', text: 'つながり方' }));
517
- section.append(workspaceLedger(doc, {
518
- className: 'bb-graph-relation-type-ledger',
519
- ariaLabel: 'つながり方',
520
- columns: GRAPH_RELATION_TYPE_LEDGER_COLUMNS,
521
- rows: payload.relations.map((item) => ({
522
- key: item.id,
523
- cells: [
524
- relationLabel(item.id),
525
- `${typeLabel(item.from)} → ${typeLabel(item.to)}`,
526
- ownLabel(GRAPH_RELATION_LABELS, item.id)?.meaning ?? textOrNull(item.meaning) ?? '',
527
- Number.isInteger(item.count) && Number.isInteger(item.activeCount)
528
- ? `${item.count}件(うち有効 ${item.activeCount}件)`
529
- : countText(item),
530
- ],
531
- })),
532
- }));
533
- section.append(makeElement(doc, 'h3', { className: 'bb-gr-subheading', text: '情報の種類' }));
534
- section.append(workspaceLedger(doc, {
535
- className: 'bb-graph-entity-type-ledger',
536
- ariaLabel: '情報の種類',
537
- columns: GRAPH_ENTITY_TYPE_LEDGER_COLUMNS,
538
- rows: payload.entityTypes.map((item) => ({
539
- key: item.id,
540
- cells: [typeLabel(item.id), typeMeaning(item), countText(item)],
541
- })),
542
- }));
543
- const corrections = writable && isRecord(payload.corrections) ? payload.corrections : null;
544
- if (corrections) {
545
- section.append(makeElement(doc, 'h3', { className: 'bb-gr-subheading', text: 'この画面で直せること' }));
546
- const list = makeElement(doc, 'ul', { className: 'bb-graph-plain-list' });
547
- list.append(
548
- makeElement(doc, 'li', { text: `記録: ${fieldNames(corrections.entityFields)}(プロジェクトは${fieldNames(corrections.projectFields)}も)` }),
549
- makeElement(doc, 'li', { text: `関係: ${fieldNames(corrections.edgeFields)}` }),
550
- makeElement(doc, 'li', { text: `新しく加えられる関係: ${(Array.isArray(corrections.newEdgeRelations) ? corrections.newEdgeRelations : []).map(relationLabel).join('・')}` }),
551
- makeElement(doc, 'li', { text: '記録と関係は削除しません。終わったものは終了日で表します。関係の種類や相手を変えるときは、今の関係を終えてから新しく加えます。' }),
552
- );
553
- section.append(list);
554
- }
555
- section.append(recordDetails(doc, [
556
- ['定義ID', textOrNull(ontology.id) ?? '未確認'],
557
- ['定義の版', version ?? '未確認'],
558
- ['release digest', textOrNull(ontology.releaseDigest) ?? '未確認'],
559
- ]));
560
- return section;
561
- }
562
-
563
572
  /** The record a host's 出典 notice may point at: the selected record once it is read. */
564
573
  function sourceSubject() {
565
574
  const entity = state.detail?.state === 'ok' ? state.detail.payload.entity : null;
566
575
  return entity ? { id: entity.id, type: entity.type, metadata: entity.metadata ?? {} } : null;
567
576
  }
568
577
 
569
- function renderWorkspace() {
578
+ /** Where the records come from: the host's notice, or this Mac's Graph. */
579
+ function sourceNoticeElement() {
570
580
  const dir = state.search.state === 'ok' ? textOrNull(state.search.payload.source?.dataDir) : null;
581
+ return workspaceHostNotice(doc, hostSourceNotice(sourceNotice, sourceSubject())) ?? workspaceNotice(doc, {
582
+ label: '出典',
583
+ text: `このMacのGraph${dir ? `(${dir})` : ''}だけを表示します。ここで直した内容は、同じGraphを読むMCPの search・get_context・resolve_entity で次から使われます。`,
584
+ });
585
+ }
586
+
587
+ function renderWorkspace() {
588
+ if (state.detail) return [renderDetailPage()];
571
589
  return [
572
590
  workspacePageHeader(doc, {
573
591
  crumbs: context.crumbs,
@@ -575,225 +593,241 @@ export function createGraphRegistryView({
575
593
  lead: writable ? 'Graphに何がどう登録されているかを確かめ、誤りを直します。' : 'Graphに何がどう登録されているかを確かめます。',
576
594
  source: context.source,
577
595
  }),
578
- workspaceHostNotice(doc, hostSourceNotice(sourceNotice, sourceSubject())) ?? workspaceNotice(doc, {
579
- label: '出典',
580
- text: `このMacのGraph${dir ? `(${dir})` : ''}だけを表示します。ここで直した内容は、同じGraphを読むMCPの search・get_context・resolve_entity で次から使われます。`,
581
- }),
582
- state.detail?.state === 'ok' ? renderGraphIssues(doc, state.detail.payload.issues) : null,
583
596
  renderSearchForm(),
584
597
  renderResults(),
585
- own ? renderOwnShare() : null,
586
- renderOntology(),
587
598
  ];
588
599
  }
589
600
 
590
- /** The owner's own share: the same search on its own routes, never among the host's results. */
591
- function renderOwnShare() {
592
- const ownState = state.own;
593
- const notice = ownShareStateNotice(doc, ownState.search, { onRetry: () => controller.searchOwn(), loadingText: '引き継いだ自分の分を探しています。' });
594
- if (notice) return ownShareSection(doc, { label: own.label, children: [notice] });
595
- const { payload } = ownState.search;
596
- const asOf = payload.query?.asOf ?? null;
597
- const children = [makeElement(doc, 'p', {
598
- className: 'bb-gr-caption',
599
- text: `${payload.graphEmpty === true ? '束に記録がありません' : `検索結果 ${payload.total}件`}${payload.truncated ? `(うち${payload.results.length}件を表示)` : ''}`,
600
- })];
601
- if (payload.graphEmpty !== true) {
602
- children.push(workspaceLedger(doc, {
603
- className: 'bb-graph-entity-ledger bb-graph-own-ledger',
604
- ariaLabel: `${own.label}の検索結果`,
605
- columns: GRAPH_ENTITY_LEDGER_COLUMNS,
606
- empty: '条件に合う記録は、この束にはありません。',
607
- rows: payload.results.map((entity) => ({
608
- key: entity.id,
609
- selected: entity.id === ownState.detail?.id,
610
- className: entity.active ? '' : 'is-ended',
611
- onSelect: (id) => { void controller.openOwnEntity(id); },
612
- cells: [
613
- { primary: entity.name, secondary: entity.id },
614
- typeLabel(entity.type),
615
- textCell(entity.summary, '要約なし'),
616
- validityLabel(entity, asOf ?? now().toISOString()),
617
- ],
618
- })),
619
- }));
620
- }
621
- if (ownState.detail) {
622
- children.push(ownEntityDetail(doc, ownState.detail, {
623
- onClose: () => controller.openOwnEntity(null),
624
- onRetry: () => controller.openOwnEntity(ownState.detail.id),
625
- onOpen: (id) => controller.openOwnEntity(id),
626
- }));
627
- }
628
- return ownShareSection(doc, { label: own.label, handover: payload.handover, children });
601
+ // -------------------------------------------------------------------------
602
+ // The record's own screen: what it is, how it connects, the evidence, and how to fix it.
603
+
604
+ function openEntityHead() {
605
+ const detail = state.detail;
606
+ return (detail.state === 'ok' ? detail.payload.entity : null) ?? detail.pending ?? { id: detail.id, name: detail.id, type: null };
629
607
  }
630
608
 
631
- // -------------------------------------------------------------------------
632
- // Right rail
633
-
634
- function relationItem(edge, entity, asOf) {
635
- const labels = ownLabel(GRAPH_RELATION_LABELS, edge.relation);
636
- const item = makeElement(doc, 'li', { className: `bb-graph-compact${edge.active ? '' : ' is-ended'}` });
637
- const head = makeElement(doc, 'div', { className: 'bb-graph-compact-head' });
638
- const phrase = makeElement(doc, 'span', { className: 'bb-graph-relation' });
639
- phrase.append(
640
- badge(doc, relationLabel(edge.relation), 'accent'),
641
- button(doc, edge.counterpart.name, () => controller.openEntity(edge.counterpart.id, {
609
+ function pageSection(title, children, className = '') {
610
+ const section = makeElement(doc, 'section', { className: `bb-gr-page-section${className ? ` ${className}` : ''}`, attrs: { 'aria-label': title } });
611
+ section.append(makeElement(doc, 'h2', { className: 'bb-gr-page-title', text: title }), ...children.filter(Boolean));
612
+ return section;
613
+ }
614
+
615
+ /** A relation read as a sentence about this record: 「Acme に所属」「田中 太郎 が参加」. */
616
+ function relationSentence(edge, entity, { link = true } = {}) {
617
+ const line = makeElement(doc, 'span', { className: 'bb-gr-sentence' });
618
+ const name = link
619
+ ? button(doc, edge.counterpart.name, () => controller.navigate(edge.counterpart.id, {
642
620
  from: entity,
643
621
  pending: { id: edge.counterpart.id, name: edge.counterpart.name, type: edge.counterpart.type },
644
- }), { className: 'bb-graph-link' }),
645
- makeElement(doc, 'span', { text: labels ? (edge.direction === 'outgoing' ? labels.outgoing : labels.incoming) : '' }),
646
- );
647
- head.append(phrase, activityBadge(doc, edge, asOf));
648
- item.append(head, workspaceDefinition(doc, [
649
- ['関係の意味', labels?.meaning ?? edge.relation],
650
- ['相手の種類', typeLabel(edge.counterpart.type)],
651
- ['役割', textOrNull(edge.role)],
652
- ...(textOrNull(edge.context) ? [['文脈', edge.context]] : []),
653
- ['有効期間', validityText(edge.validFrom, edge.validTo)],
654
- ['出典', renderProvenance(doc, edge.provenance)],
655
- ]));
656
- const subject = edge.direction === 'outgoing'
657
- ? `${entity.name} → ${relationLabel(edge.relation)} → ${edge.counterpart.name}`
658
- : `${edge.counterpart.name} → ${relationLabel(edge.relation)} → ${entity.name}`;
659
- if (edgesWritable) {
660
- item.append(workspaceActions(doc, [
661
- workspaceButton(doc, { text: 'この関係を直す', variant: 'quiet', onClick: () => openCorrection(edge.id, () => correction.openEdge(edge, { mode: 'edit', subject })) }),
662
- ]));
663
- }
664
- item.append(recordDetails(doc, [
665
- ['関係ID', edge.id],
666
- ['関係の種類ID', edge.relation],
667
- ['起点ID', edge.fromId],
668
- ['終点ID', edge.toId],
669
- ['出典ID', edge.provenance.sourceId],
670
- ['digest', edge.digest],
671
- ]));
672
- if (edgesWritable && state.panelAt === edge.id) {
673
- const panel = correction.render(doc);
674
- if (panel) item.append(panel);
675
- }
676
- return item;
622
+ }), { className: 'bb-graph-link' })
623
+ : makeElement(doc, 'strong', { text: edge.counterpart.name });
624
+ line.append(name, makeElement(doc, 'span', { text: relationPhrase(edge) }));
625
+ const notes = [textOrNull(edge.role) ? `役割: ${edge.role}` : null, textOrNull(edge.context)].filter(Boolean);
626
+ if (notes.length > 0) line.append(makeElement(doc, 'span', { className: 'bb-gr-sentence-note', text: `(${notes.join('・')})` }));
627
+ return line;
677
628
  }
678
629
 
679
- function renderRelations(payload, direction) {
630
+ /** つながり: every relation in one list, grouped by the kind of the other record. */
631
+ function renderConnections(payload) {
680
632
  const { entity } = payload;
681
- const kind = typeLabel(entity.type);
682
- const outgoing = direction === 'outgoing';
683
- const content = [makeElement(doc, 'p', {
684
- className: 'bb-graph-block-lead',
685
- text: outgoing
686
- ? `この${kind}が起点になっている関係です。相手の名前を押すと、その記録を開きます。`
687
- : `ほかの記録からこの${kind}へ向かう関係です。相手の名前を押すと、その記録を開きます。`,
688
- })];
689
- const edges = payload[direction];
633
+ const edges = [...payload.outgoing, ...payload.incoming];
634
+ const children = [];
635
+ const unknown = ['outgoing', 'incoming'].map((direction) => relationAvailability(payload, direction)).find((item) => item.state === 'unknown');
636
+ if (unknown) {
637
+ children.push(workspaceNotice(doc, { label: '確認できない', tone: 'warning', role: 'status', text: unknown.reason }));
638
+ children.push(workspaceActions(doc, [workspaceButton(doc, { text: '再試行', variant: 'quiet', onClick: () => controller.loadEntity(entity.id, { keep: true }) })]));
639
+ }
690
640
  if (edges.length === 0) {
691
- const availability = relationAvailability(payload, direction);
692
- if (availability.state === 'unknown') {
693
- content.push(workspaceNotice(doc, {
694
- label: '未確認',
695
- tone: 'warning',
696
- role: 'status',
697
- text: availability.reason,
698
- }));
699
- content.push(workspaceActions(doc, [workspaceButton(doc, {
700
- text: '再試行',
701
- variant: 'quiet',
702
- onClick: () => controller.loadEntity(entity.id, { keep: true }),
703
- })]));
704
- } else {
705
- content.push(makeElement(doc, 'p', { className: 'bb-graph-empty', text: outgoing ? 'この記録から出る関係はありません。' : 'この記録へ入る関係はありません。' }));
641
+ if (!unknown) children.push(makeElement(doc, 'p', { className: 'bb-graph-empty', text: 'この記録とつながる記録はありません。' }));
642
+ return pageSection('つながり', children, 'bb-gr-connections');
643
+ }
644
+ const groups = new Map();
645
+ for (const edge of edges) {
646
+ const kind = kindLabel(edge.counterpart.type);
647
+ if (!groups.has(kind)) groups.set(kind, []);
648
+ groups.get(kind).push(edge);
649
+ }
650
+ const order = (kind) => {
651
+ const index = Object.values(LEADING_KINDS).indexOf(kind);
652
+ return index < 0 ? Object.keys(LEADING_KINDS).length : index;
653
+ };
654
+ for (const [kind, items] of [...groups].sort(([left], [right]) => order(left) - order(right))) {
655
+ const group = makeElement(doc, 'div', { className: 'bb-gr-connection-group', attrs: { role: 'group', 'aria-label': kind } });
656
+ group.append(makeElement(doc, 'h3', { className: 'bb-gr-connection-kind', text: `${kind}(${items.length})` }));
657
+ const list = makeElement(doc, 'ul', { className: 'bb-gr-connection-list' });
658
+ for (const edge of items) {
659
+ const item = makeElement(doc, 'li', { className: `bb-gr-connection${edge.active ? '' : ' is-ended'}` });
660
+ item.append(relationSentence(edge, entity));
661
+ // An ended or future relation says when, not only that it is not in effect.
662
+ if (!edge.active) item.append(makeElement(doc, 'span', { className: 'bb-gr-connection-validity', text: validityLabel(edge, payload.asOf) }));
663
+ list.append(item);
706
664
  }
707
- } else {
708
- const list = makeElement(doc, 'ul', { className: 'bb-graph-compact-list' });
709
- for (const edge of edges) list.append(relationItem(edge, entity, payload.asOf));
710
- content.push(list);
665
+ group.append(list);
666
+ children.push(group);
711
667
  }
712
- return workspaceRailBlock(doc, { title: outgoing ? '出る関係' : '入る関係', content });
668
+ return pageSection('つながり', children, 'bb-gr-connections');
713
669
  }
714
670
 
715
- function validityValue(entity, asOf) {
716
- const wrap = makeElement(doc, 'span', { className: 'bb-graph-inline-value' });
717
- wrap.append(makeElement(doc, 'span', { text: validityText(entity.validFrom, entity.validTo) }), activityBadge(doc, entity, asOf));
718
- return wrap;
671
+ /**
672
+ * 根拠と履歴, folded (the shared part): where the record came from, records
673
+ * that could not be read and the corrections, then its period, where each
674
+ * connection came from, and the ids.
675
+ */
676
+ function renderEvidence(payload) {
677
+ const { entity } = payload;
678
+ const edges = [...payload.outgoing, ...payload.incoming];
679
+ const details = renderEvidenceAndHistory(doc, { source: sourceNoticeElement(), issues: payload.issues, history: payload.history });
680
+ const part = (title, ...children) => {
681
+ const block = makeElement(doc, 'section', { className: 'bb-graph-evidence-part' });
682
+ block.append(makeElement(doc, 'h4', { text: title }), ...children.filter(Boolean));
683
+ details.append(block);
684
+ };
685
+ part('有効期間', workspaceDefinition(doc, [
686
+ ['有効期間', validityText(entity.validFrom, entity.validTo)],
687
+ ['読み取った日', formatDay(payload.asOf) ?? textOrNull(payload.asOf)],
688
+ ]));
689
+ if (edges.length > 0) {
690
+ const list = makeElement(doc, 'ul', { className: 'bb-gr-evidence-list', attrs: { 'aria-label': 'つながりの出典' } });
691
+ for (const edge of edges) {
692
+ const item = makeElement(doc, 'li', { className: 'bb-gr-evidence-item' });
693
+ item.append(relationSentence(edge, entity, { link: false }), workspaceDefinition(doc, [
694
+ ['有効期間', validityText(edge.validFrom, edge.validTo)],
695
+ ['出典', renderProvenance(doc, edge.provenance)],
696
+ ]), recordDetails(doc, [
697
+ ['関係ID', edge.id],
698
+ ['関係の種類ID', edge.relation],
699
+ ['起点ID', edge.fromId],
700
+ ['終点ID', edge.toId],
701
+ ['出典ID', edge.provenance.sourceId],
702
+ ['digest', edge.digest],
703
+ ], { title: 'この関係のID(技術情報)' }));
704
+ list.append(item);
705
+ }
706
+ part('つながりの出典', list);
707
+ }
708
+ part('ID', recordDetails(doc, [['記録ID', entity.id], ['種類ID', entity.type], ['digest', entity.digest], ['読み取った時点', payload.asOf]], { title: 'この記録のID(技術情報)' }));
709
+ // The fold's own summary names the part, so no heading repeats it.
710
+ const section = makeElement(doc, 'section', { className: 'bb-gr-page-section bb-gr-evidence-section', attrs: { 'aria-label': '根拠と履歴' } });
711
+ section.append(details);
712
+ return section;
719
713
  }
720
714
 
721
- function renderRail() {
722
- const detail = state.detail;
723
- if (!detail) {
724
- return [workspaceDetailEmpty(doc, {
725
- mark: 'G',
726
- title: '記録を選択',
727
- text: '検索結果から選ぶと、別名・要約と、出る関係・入る関係を出典つきで表示します。',
728
- })];
715
+ /** 直す: first what a fix reaches, then the entries; a correction opens where it was asked for. */
716
+ function renderFix(payload) {
717
+ const { entity } = payload;
718
+ const noteSubject = { id: entity.id, type: entity.type, metadata: payload.entity?.metadata ?? {} };
719
+ const edges = [...payload.outgoing, ...payload.incoming];
720
+ if (!writable) {
721
+ const note = hostReadOnlyNote(doc, readOnlyNote, noteSubject);
722
+ return note ? pageSection('直す', [note], 'bb-gr-fix') : null;
729
723
  }
724
+ const canFix = scope.entity(entity.type) && scope.fields(graphEntityCorrectionFields(entity.type)).length > 0;
725
+ const relatable = newRelationOptions(entity.type).length > 0 && scope.createEdges;
726
+ const editableEdges = scope.edges ? edges : [];
730
727
  const children = [];
731
- const previous = state.trail[state.trail.length - 1];
732
- if (previous) {
733
- children.push(button(doc, `← 前の記録(${previous.name})に戻る`, () => controller.back(), { className: 'bb-graph-link bb-graph-back' }));
728
+ const counterparts = [...new Set(edges.map((edge) => edge.counterpart.name))];
729
+ const reach = makeElement(doc, 'ul', { className: 'bb-graph-plain-list bb-gr-reach' });
730
+ reach.append(makeElement(doc, 'li', { text: `この${kindLabel(entity.type)}を名前・別名で探す検索と、MCPの search・get_context・resolve_entity(次の呼び出しから)` }));
731
+ if (counterparts.length > 0) {
732
+ const shown = counterparts.slice(0, 5).join('、');
733
+ reach.append(makeElement(doc, 'li', { text: `つながる${counterparts.length}件の記録(${shown}${counterparts.length > 5 ? ' ほか' : ''})の詳細に出る、この記録の名前とつながり` }));
734
734
  }
735
- const loaded = detail.state === 'ok' ? detail.payload : null;
736
- const entity = loaded?.entity ?? detail.pending ?? { id: detail.id, name: detail.id, type: null };
737
- children.push(workspaceRailHead(doc, { kicker: entity.type ? typeLabel(entity.type) : '記録', title: entity.name, sub: entity.id }));
738
- if (!loaded) {
739
- children.push(graphStateNotice(doc, detail, { onRetry: () => controller.loadEntity(detail.id), loadingText: 'この記録を読み込んでいます。' }));
740
- return children;
735
+ children.push(makeElement(doc, 'p', { className: 'bb-graph-block-lead', text: '直す前に、影響する先を確かめてください。直した内容は次に効きます。' }), reach);
736
+ const entries = [
737
+ canFix ? '「この記録を直す」' : null,
738
+ relatable ? '「関係を加える」' : null,
739
+ edgesWritable && editableEdges.length > 0 ? '関係ごとの「この関係を直す」' : null,
740
+ ].filter(Boolean);
741
+ if (entries.length > 0) {
742
+ const last = entries.at(-1);
743
+ const list = entries.length === 1 ? last : `${entries.slice(0, -1).join('、')}、または${last}`;
744
+ children.push(makeElement(doc, 'p', {
745
+ className: 'bb-gr-fix-flow',
746
+ text: `誤りを見つけたときの訂正入口です。${list}から変更案を入力します。承認待ちの提出ではなく、保存するとその場でGraphに反映され、理由と変更前後が履歴に残ります。保存後に読み直して一致を確かめ、確認できた内容は、次のMCPの search・get_context・resolve_entity で使われます。`,
747
+ }));
741
748
  }
742
- // The record a host's read-only note may point at.
743
- const noteSubject = { id: entity.id, type: entity.type, metadata: loaded.entity?.metadata ?? {} };
744
- const summaryRows = [
745
- ['別名', entity.aliases.length > 0 ? entity.aliases.join('、') : { text: 'なし', className: 'is-unrecorded' }],
746
- ['要約', textOrNull(entity.summary)],
747
- ['有効期間', validityValue(entity, loaded.asOf)],
748
- ...(entity.type === 'project' ? [['目的', textOrNull(entity.goal)], ['状態', textOrNull(entity.status)]] : []),
749
- ...(state.applied.asOfDay ? [['見ている時点', `${state.applied.asOfDay}(有効時点で絞っています)`]] : []),
750
- ];
751
- children.push(workspaceRailBlock(doc, {
752
- title: '概要',
753
- content: [
754
- workspaceDefinition(doc, summaryRows),
755
- recordDetails(doc, [['記録ID', entity.id], ['種類ID', entity.type], ['digest', entity.digest], ['読み取った時点', loaded.asOf]]),
756
- ],
757
- }));
758
- if (writable) {
759
- const canFix = scope.entity(entity.type) && scope.fields(graphEntityCorrectionFields(entity.type)).length > 0;
760
- const relatable = newRelationOptions(entity.type).length > 0;
761
- const related = loaded.outgoing.length + loaded.incoming.length > 0;
762
- const correctionEntries = [
763
- canFix ? '「この記録を直す」' : null,
764
- relatable && scope.createEdges ? '「関係を加える」' : null,
765
- related && scope.edges ? '関係ごとの「この関係を直す」' : null,
766
- ].filter(Boolean);
767
- if (correctionEntries.length > 0) {
768
- const lastEntry = correctionEntries.at(-1);
769
- const entries = correctionEntries.length === 1
770
- ? lastEntry
771
- : `${correctionEntries.slice(0, -1).join('、')}、または${lastEntry}`;
772
- children.push(workspaceRailBlock(doc, {
773
- title: '訂正の流れ',
774
- content: makeElement(doc, 'p', {
775
- text: `誤りを見つけたときの訂正入口です。${entries}から変更案を入力します。承認待ちの提出ではなく、保存するとその場でGraphに反映され、理由と変更前後が履歴に残ります。保存後に読み直して一致を確かめ、確認できた内容は、次のMCPの search・get_context・resolve_entity で使われます。`,
776
- }),
749
+ const actions = [
750
+ canFix ? workspaceButton(doc, { text: 'この記録を直す', onClick: () => openCorrection('entity', () => correction.openEntity(entity, { title: 'この記録を直す' })) }) : null,
751
+ relatable ? workspaceButton(doc, { text: '関係を加える', onClick: () => openCorrection('entity', () => correction.openCreate(entity, { title: '関係を加える' })) }) : null,
752
+ ].filter(Boolean);
753
+ if (actions.length > 0) children.push(workspaceActions(doc, actions));
754
+ // A correction opened on a relation shows inside that relation; any other shows here.
755
+ const inRelation = editableEdges.some((edge) => edge.id === state.panelAt);
756
+ if (!inRelation) children.push(correction.render(doc));
757
+ if (edgesWritable && editableEdges.length > 0) {
758
+ const list = makeElement(doc, 'ul', { className: 'bb-gr-fix-relations', attrs: { 'aria-label': '関係を直す' } });
759
+ for (const edge of editableEdges) {
760
+ const item = makeElement(doc, 'li', { className: 'bb-gr-fix-relation' });
761
+ const subject = edge.direction === 'outgoing'
762
+ ? `${entity.name} → ${relationLabel(edge.relation)} → ${edge.counterpart.name}`
763
+ : `${edge.counterpart.name} → ${relationLabel(edge.relation)} → ${entity.name}`;
764
+ item.append(relationSentence(edge, entity, { link: false }), workspaceButton(doc, {
765
+ text: 'この関係を直す',
766
+ variant: 'quiet',
767
+ onClick: () => openCorrection(edge.id, () => correction.openEdge(edge, { mode: 'edit', subject })),
777
768
  }));
769
+ if (state.panelAt === edge.id) {
770
+ const panel = correction.render(doc);
771
+ if (panel) item.append(panel);
772
+ }
773
+ list.append(item);
778
774
  }
779
- const actions = [
780
- canFix ? workspaceButton(doc, { text: 'この記録を直す', onClick: () => openCorrection('entity', () => correction.openEntity(entity, { title: 'この記録を直す' })) }) : null,
781
- relatable && scope.createEdges
782
- ? workspaceButton(doc, { text: '関係を加える', onClick: () => openCorrection('entity', () => correction.openCreate(entity, { title: '関係を加える' })) })
783
- : null,
784
- ].filter(Boolean);
785
- if (actions.length > 0) children.push(workspaceActions(doc, actions));
786
- // A correction opened on a relation shows inside that relation; any other shows here.
787
- const inRelation = [...loaded.outgoing, ...loaded.incoming].some((edge) => edge.id === state.panelAt);
788
- if (!inRelation) children.push(correction.render(doc));
789
- // The host's reason for a control it withholds, where that control would be.
790
- if (!canFix || (relatable && !scope.createEdges) || (related && !scope.edges)) children.push(hostReadOnlyNote(doc, readOnlyNote, noteSubject));
791
- } else {
775
+ children.push(list);
776
+ }
777
+ // The host's reason for a control it withholds, where that control would be.
778
+ if (!canFix || (newRelationOptions(entity.type).length > 0 && !scope.createEdges) || (edges.length > 0 && !scope.edges)) {
792
779
  children.push(hostReadOnlyNote(doc, readOnlyNote, noteSubject));
793
780
  }
794
- children.push(renderRelations(loaded, 'outgoing'), renderRelations(loaded, 'incoming'));
795
- children.push(renderCorrectionHistory(doc, loaded.history));
796
- return children;
781
+ return pageSection('直す', children, 'bb-gr-fix');
782
+ }
783
+
784
+ /**
785
+ * The open record as its own screen: back to the list, its name and kind, then
786
+ * 何者か → つながり → 根拠と履歴 → 直す. 使われた場所 is not drawn: no read path
787
+ * reaches the work, judgments and minutes that mention a record yet.
788
+ */
789
+ function renderDetailPage() {
790
+ const detail = state.detail;
791
+ const loaded = detail.state === 'ok' ? detail.payload : null;
792
+ const entity = openEntityHead();
793
+ const page = makeElement(doc, 'article', { className: 'bb-gr-detail', attrs: { id: DETAIL_ID, 'aria-label': `${entity.name}の詳細` } });
794
+ const nav = makeElement(doc, 'nav', { className: 'bb-gr-detail-nav', attrs: { 'aria-label': '戻る' } });
795
+ nav.append(button(doc, '← 情報と関係の一覧に戻る', () => controller.backToList(), { className: 'bb-graph-link bb-gr-detail-back' }));
796
+ const previous = state.trail[state.trail.length - 1];
797
+ if (previous) nav.append(button(doc, `← 前の記録(${previous.name})に戻る`, () => controller.back(), { className: 'bb-graph-link bb-gr-detail-back' }));
798
+ page.append(nav);
799
+ const head = makeElement(doc, 'header', { className: 'bb-gr-detail-head' });
800
+ head.append(makeElement(doc, 'small', { className: 'bb-gr-detail-kind', text: entity.type ? kindLabel(entity.type) : '記録' }));
801
+ // The heading keeps focus across the redraw that follows the record's read.
802
+ const title = makeElement(doc, 'h1', { text: entity.name, attrs: { tabindex: '-1', 'data-detail-heading': '', 'data-focus-key': 'detail-heading' } });
803
+ head.append(title);
804
+ if (loaded && !loaded.entity.active) head.append(activityBadge(doc, loaded.entity, loaded.asOf));
805
+ if (state.trail.length > 0) head.append(makeElement(doc, 'p', { className: 'bb-gr-detail-trail', text: `「${state.trail[0].name}」のつながりからたどっています。` }));
806
+ page.append(head);
807
+ if (!loaded) {
808
+ page.append(graphStateNotice(doc, detail, { onRetry: () => controller.loadEntity(detail.id), loadingText: 'この記録を読み込んでいます。' }));
809
+ return page;
810
+ }
811
+ const record = loaded.entity;
812
+ page.append(
813
+ pageSection('何者か', [workspaceDefinition(doc, [
814
+ ['種類', kindLabel(record.type)],
815
+ ['説明', textOrNull(record.summary) ?? { text: '説明の記録なし', className: 'is-unrecorded' }],
816
+ ['別名', record.aliases.length > 0 ? record.aliases.join('、') : { text: 'なし', className: 'is-unrecorded' }],
817
+ ...(record.type === 'project' ? [['目的', textOrNull(record.goal)], ['状態', textOrNull(record.status)]] : []),
818
+ ...(state.applied.asOfDay ? [['見ている日', `${state.applied.asOfDay} の状態`]] : []),
819
+ ])], 'bb-gr-identity'),
820
+ renderConnections(loaded),
821
+ renderEvidence(loaded),
822
+ );
823
+ const fix = renderFix(loaded);
824
+ if (fix) page.append(fix);
825
+ return page;
826
+ }
827
+
828
+ /** The rail stays empty: the record opens as its own screen in the workspace. */
829
+ function renderRail() {
830
+ return [];
797
831
  }
798
832
 
799
833
  function closeCorrection() {
@@ -809,93 +843,133 @@ export function createGraphRegistryView({
809
843
  return normalizeSearch(await client.read('/search', searchParams(query)));
810
844
  }
811
845
 
812
- /** Keeps the record in the rail only when it is among the new results. */
813
- async function followResults() {
814
- const detail = state.detail;
815
- if (!detail) return;
816
- const listed = state.search.state === 'ok' && state.search.payload.results.some((entity) => entity.id === detail.id);
817
- if (!listed) {
818
- closeCorrection();
819
- state.detail = null;
820
- state.trail = [];
821
- return;
846
+ /** The focused search control, so a redraw (which rebuilds the form) can give focus back. */
847
+ function focusedControl() {
848
+ const active = doc.activeElement;
849
+ const key = active?.getAttribute?.('data-focus-key');
850
+ if (!key || typeof root.contains !== 'function' || !root.contains(active)) return null;
851
+ return { key, start: active.selectionStart ?? null, end: active.selectionEnd ?? null };
852
+ }
853
+
854
+ function restoreFocus(saved) {
855
+ if (!saved || typeof root.querySelector !== 'function') return;
856
+ const control = root.querySelector(`[data-focus-key="${saved.key}"]`);
857
+ if (!control || doc.activeElement === control) return;
858
+ control.focus?.({ preventScroll: true });
859
+ if (saved.start !== null && typeof control.setSelectionRange === 'function') {
860
+ try { control.setSelectionRange(saved.start, saved.end ?? saved.start); } catch { /* a date field has no caret */ }
822
861
  }
823
- await controller.loadEntity(detail.id, { keep: true });
824
862
  }
825
863
 
864
+ /** Puts the opened record's screen at the top, with focus on its name. */
865
+ function showDetail() {
866
+ if (typeof root.querySelector !== 'function') return;
867
+ const heading = root.querySelector('[data-detail-heading]');
868
+ if (!heading) return;
869
+ heading.scrollIntoView?.({ block: 'start' });
870
+ heading.focus?.({ preventScroll: true });
871
+ }
872
+
873
+ // Escape on the record's screen goes back to the list, like 「← 一覧に戻る」.
874
+ root.addEventListener?.('keydown', (event) => {
875
+ if (event?.key !== 'Escape' || !state.detail || correction.form) return;
876
+ event.preventDefault?.();
877
+ controller.backToList();
878
+ });
879
+
826
880
  const controller = {
827
881
  get state() { return state; },
828
882
  get correction() { return correction; },
829
883
  render() {
884
+ // Redrawing mid-conversion would drop the characters being composed; draw when it ends.
885
+ if (composing) {
886
+ renderDeferred = true;
887
+ return controller;
888
+ }
889
+ renderDeferred = false;
890
+ const saved = focusedControl();
830
891
  layout.render(renderWorkspace(), renderRail());
831
- if (focusRow) {
892
+ if (focusRow && !state.detail) {
893
+ // Back on the list: the row of the record just left, in view and focused.
832
894
  focusLedgerRow(root, focusRow);
895
+ [...(root.querySelectorAll?.('.bb-ws-ledger-row[data-key]') ?? [])]
896
+ .find((row) => row.getAttribute('data-key') === focusRow)?.scrollIntoView?.({ block: 'center' });
833
897
  focusRow = null;
898
+ } else {
899
+ restoreFocus(saved);
900
+ }
901
+ if (revealDetail && state.detail) {
902
+ revealDetail = false;
903
+ showDetail();
834
904
  }
835
905
  return controller;
836
906
  },
837
- /** Runs the search in the form. The record in the rail stays while it is among the results. */
907
+ /**
908
+ * Runs the search in the form. The last results stay on screen while the
909
+ * new ones are read.
910
+ */
838
911
  async search() {
912
+ cancelScheduledSearch();
839
913
  if (state.query.asOfDay && !dayToRfc3339(state.query.asOfDay)) {
840
- state.queryMessage = '有効時点の日付の形式が正しくありません。';
914
+ state.queryMessage = '日付の形式が正しくありません。';
841
915
  controller.render();
842
916
  return state.search;
843
917
  }
844
918
  state.queryMessage = null;
845
919
  state.applied = { ...state.query };
846
- state.search = { state: 'loading' };
920
+ if (state.search.state === 'ok') state.searching = true;
921
+ else state.search = { state: 'loading' };
847
922
  controller.render();
848
923
  const applied = state.applied;
849
- // The owner's own share answers the same search on its own; neither waits on the other's failure.
850
- const owning = own ? controller.searchOwn() : null;
851
924
  const result = await readSearch(applied);
852
- if (state.applied !== applied) {
853
- await owning;
854
- return state.search;
855
- }
925
+ if (state.applied !== applied) return state.search;
926
+ state.searching = false;
856
927
  state.search = result;
857
- await followResults();
858
928
  controller.render();
859
- await owning;
860
929
  return state.search;
861
930
  },
862
- /** Runs the applied search on the owner's own share (with `ownShare`); a failure stays in that section. */
863
- async searchOwn() {
864
- if (!own) return null;
865
- const applied = state.applied;
866
- state.own.search = { state: 'loading' };
867
- controller.render();
868
- const result = await own.client.read('/search', searchParams(applied));
869
- if (state.applied !== applied) return state.own.search;
870
- // An empty answer is an answer here: the host confirms the whole snapshot was searched.
871
- state.own.search = result.state === 'ok' && !(Array.isArray(result.payload.results) && result.payload.results.every(isEntityView)) ? INVALID : result;
872
- const openId = state.own.detail?.id;
873
- if (openId && !(state.own.search.state === 'ok' && state.own.search.payload.results.some((entity) => entity.id === openId))) state.own.detail = null;
874
- controller.render();
875
- return state.own.search;
931
+ /** Removes one condition (`q`, `type` or `asOfDay`) and searches again. */
932
+ async clearFilter(key) {
933
+ if (!Object.hasOwn(state.query, key)) return state.search;
934
+ state.query = { ...state.query, [key]: '' };
935
+ const result = controller.search();
936
+ root.querySelector?.('[data-focus-key="q"]')?.focus?.({ preventScroll: true });
937
+ return result;
876
938
  },
877
- /** Opens (or with null closes) a record of the owner's own share below its results. */
878
- async openOwnEntity(id) {
879
- if (!own) return null;
880
- const target = textOrNull(id);
881
- if (!target) {
882
- state.own.detail = null;
883
- controller.render();
884
- return null;
885
- }
886
- const listed = state.own.search.state === 'ok' ? state.own.search.payload.results.find((entity) => entity.id === target) : null;
887
- const pending = listed ?? (state.own.detail?.state === 'ok' ? [...state.own.detail.payload.outgoing, ...state.own.detail.payload.incoming]
888
- .map((edge) => edge.counterpart).find((counterpart) => counterpart.id === target) : null) ?? null;
889
- state.own.detail = { id: target, state: 'loading', pending: pending ? { id: target, name: pending.name, type: pending.type } : null };
890
- controller.render();
891
- const result = await own.client.read(`/entities/${encodeURIComponent(target)}`, { as_of: asOfParam() });
892
- if (state.own.detail?.id !== target) return state.own.detail;
893
- const base = { id: target, pending: state.own.detail.pending };
894
- state.own.detail = result.state === 'error' && result.status === 404
895
- ? { ...base, state: 'not_found', reason: 'この記録は束の中に見つかりません。' }
896
- : { ...base, ...normalizeEntityDetail(result) };
939
+ /** Removes every condition and searches again. */
940
+ async clearFilters() {
941
+ state.query = { q: '', type: '', asOfDay: '' };
942
+ const result = controller.search();
943
+ root.querySelector?.('[data-focus-key="q"]')?.focus?.({ preventScroll: true });
944
+ return result;
945
+ },
946
+ /**
947
+ * Closes the record's screen and shows the list again, with the row of the
948
+ * record that was opened from it focused. The host calls this when its
949
+ * address no longer names a record (the browser's back).
950
+ */
951
+ closeEntity() {
952
+ if (!state.detail) return null;
953
+ closeCorrection();
954
+ const anchor = state.anchor;
955
+ state.detail = null;
956
+ state.anchor = null;
957
+ state.trail = [];
958
+ if (anchor) focusRow = anchor;
897
959
  controller.render();
898
- return state.own.detail;
960
+ return null;
961
+ },
962
+ /** 「← 一覧に戻る」 and Escape: closes the record and tells the host the address is the list again. */
963
+ backToList() {
964
+ controller.closeEntity();
965
+ onRouteChange?.(null, { back: true });
966
+ return null;
967
+ },
968
+ /** Opens a record the owner chose (a row, or a connection) and tells the host its address. */
969
+ async navigate(id, options = {}) {
970
+ // The address first, while the list is still where the owner left it (the browser keeps that scroll for 戻る).
971
+ onRouteChange?.(id, { back: false });
972
+ return controller.openEntity(id, options);
899
973
  },
900
974
  /** Reads the last search again without showing it as loading (after a correction). */
901
975
  async refreshResults() {
@@ -910,7 +984,7 @@ export function createGraphRegistryView({
910
984
  async loadEntity(id, { keep = false, pending } = {}) {
911
985
  const previous = state.detail;
912
986
  if (!(keep && previous?.id === id && previous.state === 'ok')) {
913
- // What is already known about the record (name, kind) heads the rail while it loads.
987
+ // What is already known about the record (name, kind) heads its detail while it loads.
914
988
  const known = pending ?? (previous?.id === id ? previous.payload?.entity ?? previous.pending ?? null : null);
915
989
  state.detail = { id, state: 'loading', pending: known ? { id, name: known.name, type: known.type } : null };
916
990
  controller.render();
@@ -925,13 +999,20 @@ export function createGraphRegistryView({
925
999
  return state.detail;
926
1000
  },
927
1001
  /**
928
- * Opens a record in the rail. `from` (the record being left) makes 戻る
929
- * return to it; a row of the results starts a new trail.
1002
+ * Opens a record as its own screen. `from` (the record being left) makes
1003
+ * 前の記録に戻る return to it; otherwise a new trail starts. The host calls
1004
+ * this for an address that names a record; it does not report back.
930
1005
  */
931
1006
  async openEntity(id, { from, pending } = {}) {
1007
+ // The host's address catching up with a record already open changes nothing.
1008
+ if (!from && state.detail?.id === id) return state.detail;
932
1009
  closeCorrection();
933
1010
  if (from) state.trail.push({ id: from.id, name: from.name, type: from.type });
934
- else state.trail = [];
1011
+ else {
1012
+ state.trail = [];
1013
+ state.anchor = id;
1014
+ }
1015
+ revealDetail = true;
935
1016
  const listed = state.search.state === 'ok' ? state.search.payload.results.find((entity) => entity.id === id) : null;
936
1017
  return controller.loadEntity(id, { keep: true, pending: pending ?? listed ?? null });
937
1018
  },
@@ -939,6 +1020,8 @@ export function createGraphRegistryView({
939
1020
  closeCorrection();
940
1021
  const previous = state.trail.pop();
941
1022
  if (!previous) return state.detail;
1023
+ revealDetail = true;
1024
+ onRouteChange?.(previous.id, { back: false });
942
1025
  return controller.loadEntity(previous.id, { pending: previous });
943
1026
  },
944
1027
  async loadOntology({ keep = false } = {}) {