@unson/brainbase-mcp 0.42.0 → 0.44.0

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.
@@ -61,62 +61,63 @@ const NAV_ICON_PATHS = Object.freeze({
61
61
  ontology: ['M4 4h6v6H4z', 'M14 14h6v6h-6z', 'M14 4h6v6h-6z', 'M10 7h4', 'M17 10v4'],
62
62
  // World view (extension id `world`, ledger P17).
63
63
  world: ['M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z', 'M3 12h18', 'M12 3c2.5 2.6 3.8 5.6 3.8 9s-1.3 6.4-3.8 9c-2.5-2.6-3.8-5.6-3.8-9S9.5 5.6 12 3z'],
64
- // Areas whose screens have their own icons (ledger D10, stage 2): a briefcase, and
65
- // the organization edition's gear.
66
- 'area:work': ['M4 8h16v11H4z', 'M9 8V5h6v3', 'M4 13h16'],
64
+ // 設定 (ledger D10): the organization edition's gear.
67
65
  'area:settings': ['M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915', 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z'],
68
66
  });
69
67
  // Screens without their own icon (for example a private product's extension).
70
68
  const DEFAULT_NAV_ICON_PATHS = Object.freeze(['M5 20V10m7 10V4m7 16v-7']);
71
69
  const SVG_NS = 'http://www.w3.org/2000/svg';
72
70
 
73
- // The menu's areas (brainbase-project ledger D10): the same order and names as
74
- // the organization edition. The left side lists only the areas; the screens of
75
- // the current area are tabs above the page (stage 2). 目的と現状 is the only
76
- // screen of its area and keeps its own name. Inside an area, `order` lists the
77
- // screens first in that order; `defaultScreen` opens when nothing is remembered.
78
- const NAV_AREAS = Object.freeze([
79
- Object.freeze({ id: 'purpose', label: null, order: ['objectives'], icon: 'objectives' }),
80
- Object.freeze({ id: 'work', label: '仕事', order: ['projects', 'minutes'], defaultScreen: 'projects', icon: 'area:work' }),
81
- Object.freeze({ id: 'outcomes', label: '成果', order: ['value-proofs'], icon: 'value-proofs' }),
82
- Object.freeze({ id: 'information', label: '情報と関係', order: ['graph', 'people', 'memory'], defaultScreen: 'graph', icon: 'graph' }),
83
- Object.freeze({ id: 'settings', label: '設定', order: ['ontology'], defaultScreen: 'ontology', icon: 'area:settings' }),
71
+ // The menu's sections (brainbase-project ledger D10, 2026-10-17 revision): the
72
+ // destinations themselves under small headings, in the organization edition's
73
+ // order, with 設定 at the bottom. Screens OSS Personal does not have are not
74
+ // listed. `screenId` makes the heading open that screen (プロジェクトと関係者);
75
+ // `area` turns the section's screens into one line with tabs (設定). Inside a
76
+ // section, `order` lists the screens first in that order.
77
+ const NAV_SECTIONS = Object.freeze([
78
+ Object.freeze({ id: 'meetings', label: '会議', order: ['minutes'] }),
79
+ Object.freeze({ id: 'project', label: 'プロジェクト', screenId: 'projects', order: ['objectives'] }),
80
+ Object.freeze({ id: 'explore', label: '調べる', order: ['value-proofs', 'graph', 'people', 'memory'] }),
81
+ Object.freeze({ id: 'settings', label: null, placement: 'footer', order: ['ontology'], area: Object.freeze({ label: '設定', defaultScreen: 'ontology', icon: 'area:settings' }) }),
84
82
  ]);
85
83
 
84
+ const sectionOf = (screen) => NAV_SECTIONS.find((section) => section.id === screen.section) ?? null;
85
+
86
86
  // Menu order only: the screens' own order (and so the first screen) stays as given.
87
- // Screens without an area keep their place — those before the first area-bound
88
- // screen come first (a leading extension such as the world), the rest come last.
89
- function navLayout(screens) {
90
- const firstInArea = screens.findIndex((screen) => NAV_AREAS.some((area) => area.id === screen.area));
91
- if (firstInArea === -1) return { leading: [...screens], areas: [], trailing: [] };
92
- const leading = screens.slice(0, firstInArea);
93
- const trailing = screens.slice(firstInArea).filter((screen) => !NAV_AREAS.some((area) => area.id === screen.area));
94
- const areas = NAV_AREAS.map((area) => {
95
- const members = screens.filter((screen) => screen.area === area.id);
87
+ // Screens without a section keep their place — those before the first sectioned
88
+ // screen come first (a leading extension such as the world), the rest come after
89
+ // the sections and before 設定.
90
+ function navAreas(screens) {
91
+ const own = (screen, section = null) => ({ id: screen.id, label: null, screenIds: [screen.id], icon: screen.id, ...(section ? { section } : {}) });
92
+ const firstSectioned = screens.findIndex((screen) => sectionOf(screen));
93
+ const leading = firstSectioned === -1 ? [...screens] : screens.slice(0, firstSectioned);
94
+ const trailing = firstSectioned === -1 ? [] : screens.slice(firstSectioned).filter((screen) => !sectionOf(screen));
95
+ const sectioned = NAV_SECTIONS.map((section) => {
96
+ const members = screens.filter((screen) => screen.section === section.id && screen.id !== section.screenId);
96
97
  const rank = (screen) => {
97
- const index = area.order.indexOf(screen.id);
98
- return index === -1 ? area.order.length + members.indexOf(screen) : index;
98
+ const index = section.order.indexOf(screen.id);
99
+ return index === -1 ? section.order.length + members.indexOf(screen) : index;
99
100
  };
100
- return { area, screens: [...members].sort((a, b) => rank(a) - rank(b)) };
101
- }).filter((entry) => entry.screens.length > 0);
102
- return { leading, areas, trailing };
101
+ const ordered = [...members].sort((a, b) => rank(a) - rank(b));
102
+ if (ordered.length === 0) return { section, areas: [] };
103
+ if (section.area) {
104
+ return { section, areas: [{ id: section.id, label: section.area.label, defaultScreen: section.area.defaultScreen, screenIds: ordered.map((screen) => screen.id), icon: section.area.icon, section: section.id }] };
105
+ }
106
+ return { section, areas: ordered.map((screen) => own(screen, section.id)) };
107
+ });
108
+ const main = sectioned.filter(({ section }) => section.placement !== 'footer').flatMap(({ areas }) => areas);
109
+ const footer = sectioned.filter(({ section }) => section.placement === 'footer').flatMap(({ areas }) => areas);
110
+ return [...leading.map((screen) => own(screen)), ...main, ...trailing.map((screen) => own(screen)), ...footer];
103
111
  }
104
112
 
105
- // Every menu line is an area: a screen without an area is an area of its own.
106
- function navAreas(screens) {
107
- const layout = navLayout(screens);
108
- const own = (screen) => ({ id: screen.id, label: null, screenIds: [screen.id], icon: screen.id });
109
- return [
110
- ...layout.leading.map(own),
111
- ...layout.areas.map(({ area, screens: members }) => ({
112
- id: area.id,
113
- label: area.label,
114
- defaultScreen: area.defaultScreen,
115
- screenIds: members.map((screen) => screen.id),
116
- icon: area.icon,
117
- })),
118
- ...layout.trailing.map(own),
119
- ];
113
+ // The sections handed to the menu part; a heading opens its screen only when that screen exists.
114
+ function navSections(screens) {
115
+ return NAV_SECTIONS.map((section) => ({
116
+ id: section.id,
117
+ label: section.label,
118
+ placement: section.placement ?? 'main',
119
+ ...(section.screenId && screens.some((screen) => screen.id === section.screenId) ? { screenId: section.screenId } : {}),
120
+ }));
120
121
  }
121
122
 
122
123
  function makeIcon(doc, id) {
@@ -376,19 +377,19 @@ function mountGraphScreen(screenId, createView, extra = {}) {
376
377
  }
377
378
 
378
379
  export const LOCAL_WEB_SCREENS = Object.freeze([
379
- Object.freeze({ id: 'value-proofs', label: '判断の履歴', area: 'outcomes', usesGraph: false, rail: true, source: '判断の記録', mount: mountToday }),
380
- Object.freeze({ id: 'minutes', label: '議事録', area: 'work', usesGraph: false, rail: true, source: '正本の議事録', mount: mountMeetingMinutes }),
381
- Object.freeze({ id: 'objectives', label: '目的と現状', area: 'purpose', usesGraph: true, rail: true, source: '手元のGraph', mount: mountObjectives }),
382
- Object.freeze({ id: 'projects', label: 'プロジェクトと関係者', area: 'work', usesGraph: true, rail: true, source: '手元のGraph', mount: mountGraphScreen('projects', createGraphProjectsView) }),
380
+ Object.freeze({ id: 'value-proofs', label: '判断の履歴', section: 'explore', usesGraph: false, rail: true, source: '判断の記録', mount: mountToday }),
381
+ Object.freeze({ id: 'minutes', label: '議事録', section: 'meetings', usesGraph: false, rail: true, source: '正本の議事録', mount: mountMeetingMinutes }),
382
+ Object.freeze({ id: 'objectives', label: '目的と現状', section: 'project', usesGraph: true, rail: true, source: '手元のGraph', mount: mountObjectives }),
383
+ Object.freeze({ id: 'projects', label: 'プロジェクトと関係者', section: 'project', usesGraph: true, rail: true, source: '手元のGraph', mount: mountGraphScreen('projects', createGraphProjectsView) }),
383
384
  // A record of 情報と関係 opens as its own screen at `#graph?entity_id=…`; the list is `#graph`.
384
- Object.freeze({ id: 'graph', label: '情報と関係', area: 'information', usesGraph: true, rail: false, source: '手元のGraph', mount: mountGraphScreen('graph', createGraphRegistryView, (context) => ({
385
+ Object.freeze({ id: 'graph', label: '情報と関係', section: 'explore', usesGraph: true, rail: false, source: '手元のGraph', mount: mountGraphScreen('graph', createGraphRegistryView, (context) => ({
385
386
  onRouteChange: (entityId, options) => context.navigate?.(entityId ? `graph?entity_id=${encodeURIComponent(entityId)}` : 'graph', options),
386
387
  })) }),
387
- Object.freeze({ id: 'people', label: '人と会社', area: 'information', usesGraph: true, rail: true, source: '手元のGraph', mount: mountGraphScreen('people', createPeopleAndCompaniesView, { canCorrect: true }) }),
388
+ Object.freeze({ id: 'people', label: '人と会社', section: 'explore', usesGraph: true, rail: true, source: '手元のGraph', mount: mountGraphScreen('people', createPeopleAndCompaniesView, { canCorrect: true }) }),
388
389
  // Only the owner sees it (ledger D15); the local host has one owner, so it is always listed here.
389
- Object.freeze({ id: 'memory', label: 'あなたの記憶', area: 'information', usesGraph: false, rail: true, ownerOnly: true, source: '手元の個人KG', mount: mountPersonalMemory }),
390
+ Object.freeze({ id: 'memory', label: 'あなたの記憶', section: 'explore', usesGraph: false, rail: true, ownerOnly: true, source: '手元の個人KG', mount: mountPersonalMemory }),
390
391
  // The definitions behind 情報と関係: infrequent management work, so under settings (ledger D14).
391
- Object.freeze({ id: 'ontology', label: '情報の種類とつながり方', area: 'settings', usesGraph: true, rail: false, source: '手元のGraph', mount: mountGraphScreen('ontology', createGraphOntologyView, { canCorrect: true }) }),
392
+ Object.freeze({ id: 'ontology', label: '情報の種類とつながり方', section: 'settings', usesGraph: true, rail: false, source: '手元のGraph', mount: mountGraphScreen('ontology', createGraphOntologyView, { canCorrect: true }) }),
392
393
  ]);
393
394
 
394
395
  function renderGraphGate(doc, slot, status, onRecheck, label) {
@@ -516,6 +517,7 @@ export function createLocalWebShell({
516
517
  const areaNav = createAreaNav({
517
518
  document: doc,
518
519
  areas: areaList,
520
+ sections: navSections(screens),
519
521
  screens: screens.map((screen) => ({ id: screen.id, label: screen.label })),
520
522
  memory: areaMemory,
521
523
  linkClassName: 'bb-shell-nav-link',
@@ -698,3 +698,125 @@
698
698
  .bb-minutes-connection-note.is-unavailable {
699
699
  color: var(--bb-color-warning);
700
700
  }
701
+
702
+ /* 参加者 (story-meeting-participants-row-v1): linked names, 未確認 badges and the link panel. */
703
+ .bb-minutes-participants {
704
+ display: grid;
705
+ gap: 4px;
706
+ margin: 8px 0 4px;
707
+ padding: 8px 12px;
708
+ border: 1px solid var(--bb-color-border);
709
+ border-radius: var(--bb-radius-md, 6px);
710
+ background: var(--bb-color-surface);
711
+ }
712
+
713
+ .bb-minutes-participants[hidden] {
714
+ display: none;
715
+ }
716
+
717
+ .bb-minutes-participants-row {
718
+ display: grid;
719
+ grid-template-columns: 7em minmax(0, 1fr);
720
+ gap: 8px;
721
+ align-items: baseline;
722
+ font-size: 13px;
723
+ line-height: 1.5;
724
+ }
725
+
726
+ .bb-minutes-participant-items {
727
+ display: flex;
728
+ flex-wrap: wrap;
729
+ gap: 4px 14px;
730
+ margin: 0;
731
+ padding: 0;
732
+ list-style: none;
733
+ min-width: 0;
734
+ }
735
+
736
+ .bb-minutes-participant-items li {
737
+ display: inline-flex;
738
+ align-items: baseline;
739
+ gap: 4px;
740
+ min-width: 0;
741
+ overflow-wrap: anywhere;
742
+ }
743
+
744
+ .bb-minutes-participant-badge {
745
+ padding: 0 8px;
746
+ border: 1px dashed var(--bb-color-warning);
747
+ border-radius: 999px;
748
+ background: transparent;
749
+ color: var(--bb-color-ink);
750
+ font: inherit;
751
+ cursor: pointer;
752
+ }
753
+
754
+ .bb-minutes-participant-badge[aria-expanded="true"] {
755
+ border-style: solid;
756
+ background: var(--bb-color-warning-soft);
757
+ }
758
+
759
+ .bb-minutes-participant-undo {
760
+ padding: 0;
761
+ border: 0;
762
+ background: transparent;
763
+ color: var(--bb-color-muted);
764
+ font-size: 12px;
765
+ text-decoration: underline;
766
+ cursor: pointer;
767
+ }
768
+
769
+ .bb-minutes-participant-notice {
770
+ margin: 0;
771
+ font-size: 13px;
772
+ }
773
+
774
+ .bb-minutes-participant-panel {
775
+ display: grid;
776
+ gap: 8px;
777
+ margin-top: 4px;
778
+ padding: 10px 12px;
779
+ border: 1px solid var(--bb-color-border);
780
+ border-radius: var(--bb-radius-md, 6px);
781
+ background: var(--bb-color-paper);
782
+ font-size: 13px;
783
+ }
784
+
785
+ .bb-minutes-participant-panel-title {
786
+ margin: 0;
787
+ font-weight: 700;
788
+ }
789
+
790
+ .bb-minutes-participant-others {
791
+ margin: 0;
792
+ color: var(--bb-color-muted);
793
+ }
794
+
795
+ .bb-minutes-participant-candidates {
796
+ display: grid;
797
+ gap: 6px;
798
+ margin: 0;
799
+ padding: 0;
800
+ list-style: none;
801
+ }
802
+
803
+ .bb-minutes-participant-candidate {
804
+ display: flex;
805
+ flex-wrap: wrap;
806
+ align-items: center;
807
+ justify-content: space-between;
808
+ gap: 4px 12px;
809
+ }
810
+
811
+ .bb-minutes-participant-candidate > div {
812
+ display: grid;
813
+ min-width: 0;
814
+ }
815
+
816
+ .bb-minutes-participant-new,
817
+ .bb-minutes-participant-not-person {
818
+ display: flex;
819
+ flex-wrap: wrap;
820
+ align-items: flex-end;
821
+ gap: 6px 8px;
822
+ }
@@ -208,6 +208,52 @@ function normalizeConnections(value) {
208
208
  ]));
209
209
  }
210
210
 
211
+ // 参加者の行 (story-meeting-participants-row-v1). The host decides who is linked,
212
+ // unconfirmed or not a person; anything malformed is unreadable, never a person (P9).
213
+ const PARTICIPANT_STATES = new Set(['linked', 'unconfirmed', 'not_person']);
214
+
215
+ function normalizeParticipant(value) {
216
+ if (!isRecord(value) || !PARTICIPANT_STATES.has(value.state)) return null;
217
+ const displayName = text(value.display_name);
218
+ if (!displayName) return null;
219
+ const others = Number(value.other_meetings);
220
+ const participant = {
221
+ displayName,
222
+ state: value.state,
223
+ removable: value.removable === true,
224
+ otherMeetings: Number.isInteger(others) && others > 0 ? others : 0,
225
+ };
226
+ if (value.state === 'linked') {
227
+ const name = text(value.name);
228
+ const entityId = text(value.entity_id);
229
+ if (!name || !entityId) return null;
230
+ participant.name = name;
231
+ participant.entityId = entityId;
232
+ participant.href = connectionHref(value.href);
233
+ }
234
+ return participant;
235
+ }
236
+
237
+ function normalizeParticipants(value) {
238
+ if (!isRecord(value) || !['recorded', 'none', 'unavailable'].includes(value.status)) return { status: 'unavailable' };
239
+ if (value.status !== 'recorded') return { status: value.status };
240
+ const participants = Array.isArray(value.participants) ? value.participants.map(normalizeParticipant) : [];
241
+ if (participants.some((participant) => participant === null)) return { status: 'unavailable' };
242
+ if (!participants.length) return { status: 'none' };
243
+ return { status: 'recorded', participants, redrafting: value.redrafting === true };
244
+ }
245
+
246
+ function normalizeCandidateList(values, idField) {
247
+ if (!Array.isArray(values)) return [];
248
+ return values.flatMap((value) => {
249
+ const id = text(value?.[idField]);
250
+ const name = text(value?.name);
251
+ if (!id || !name) return [];
252
+ const evidence = Array.isArray(value.evidence) ? value.evidence.map((item) => text(item)).filter(Boolean) : [];
253
+ return [{ id, name, company: text(value.company), evidence }];
254
+ });
255
+ }
256
+
211
257
  function firstDate(values) {
212
258
  for (const value of values) {
213
259
  const date = parseDate(value);
@@ -636,6 +682,7 @@ export function createMeetingMinutesUI({
636
682
  versionActionExtensions = [],
637
683
  meetingLabel,
638
684
  meetingConnections,
685
+ meetingParticipants,
639
686
  canCreateMeeting,
640
687
  createUnavailableReason,
641
688
  canEditMinutes,
@@ -666,11 +713,14 @@ export function createMeetingMinutesUI({
666
713
  withdrawingMinutesId: null,
667
714
  // { meetingId, rows } where rows is null while the host is answering.
668
715
  connections: null,
716
+ // { meetingId, value, openName } for the 参加者 row (story-meeting-participants-row-v1).
717
+ participants: null,
669
718
  };
670
719
  let listRequestGeneration = 0;
671
720
  let detailRequestGeneration = 0;
672
721
  let createReasonSequence = 0;
673
722
  let connectionsSlot = null;
723
+ let participantsSlot = null;
674
724
  let railProjectSlot = null;
675
725
  let railProjectMeeting = null;
676
726
 
@@ -1052,6 +1102,260 @@ export function createMeetingMinutesUI({
1052
1102
  if (railProjectSlot && railProjectMeeting?.meeting_id === meetingId) fillRailProject(railProjectSlot, railProjectMeeting);
1053
1103
  }
1054
1104
 
1105
+ function hasParticipants() {
1106
+ return isRecord(meetingParticipants) && typeof meetingParticipants.read === 'function';
1107
+ }
1108
+
1109
+ function currentParticipants(meeting) {
1110
+ const value = state.participants;
1111
+ return value && value.meetingId === meeting?.meeting_id ? value : null;
1112
+ }
1113
+
1114
+ function participantError(error, fallback) {
1115
+ const reason = text(error?.message);
1116
+ return reason && !/^[a-z_]+$/u.test(reason) ? reason : fallback;
1117
+ }
1118
+
1119
+ function fillParticipants(slot, meeting) {
1120
+ const current = currentParticipants(meeting);
1121
+ const value = current?.value ?? null;
1122
+ slot.hidden = value?.status === 'none';
1123
+ if (value?.status === 'none') {
1124
+ slot.replaceChildren();
1125
+ return;
1126
+ }
1127
+ const row = makeElement(doc, 'div', { className: 'bb-minutes-participants-row' });
1128
+ row.append(makeElement(doc, 'span', { className: 'bb-minutes-connection-label', textContent: '参加者' }));
1129
+ if (!value) {
1130
+ row.append(makeElement(doc, 'p', { className: 'bb-minutes-connection-note', textContent: '読み込んでいます' }));
1131
+ slot.replaceChildren(row);
1132
+ return;
1133
+ }
1134
+ if (value.status !== 'recorded') {
1135
+ row.append(makeElement(doc, 'p', { className: 'bb-minutes-connection-note is-unavailable', textContent: '参加者を読み込めませんでした' }));
1136
+ slot.replaceChildren(row);
1137
+ return;
1138
+ }
1139
+ const list = makeElement(doc, 'ul', { className: 'bb-minutes-participant-items' });
1140
+ for (const participant of value.participants) {
1141
+ const entry = makeElement(doc, 'li', { attrs: { 'data-participant-state': participant.state } });
1142
+ if (participant.state === 'linked') {
1143
+ entry.append(connectionLink({ label: participant.name, href: participant.href }, 'bb-minutes-connection-link'));
1144
+ if (participant.name !== participant.displayName) {
1145
+ entry.append(makeElement(doc, 'span', { className: 'bb-minutes-connection-detail', textContent: `(${participant.displayName})` }));
1146
+ }
1147
+ } else if (participant.state === 'unconfirmed') {
1148
+ const open = current.openName === participant.displayName;
1149
+ const badge = button(doc, `${participant.displayName}(未確認)`, () => toggleParticipantPanel(meeting, participant.displayName), {
1150
+ className: 'bb-minutes-participant-badge',
1151
+ attrs: { 'aria-expanded': open ? 'true' : 'false' },
1152
+ });
1153
+ entry.append(badge);
1154
+ } else {
1155
+ entry.append(makeElement(doc, 'span', { textContent: participant.displayName }));
1156
+ entry.append(makeElement(doc, 'span', { className: 'bb-minutes-connection-detail', textContent: '(人ではない)' }));
1157
+ }
1158
+ if (participant.removable && typeof meetingParticipants.unlink === 'function') {
1159
+ entry.append(button(doc, '取り消す', () => void unlinkParticipant(meeting, participant), { className: 'bb-minutes-participant-undo' }));
1160
+ }
1161
+ list.append(entry);
1162
+ }
1163
+ row.append(list);
1164
+ const children = [row];
1165
+ if (value.redrafting) {
1166
+ children.push(makeElement(doc, 'p', { className: 'bb-minutes-connection-note', textContent: '名前を結んだので、この議事録を作り直しています。' }));
1167
+ }
1168
+ if (current.notice) {
1169
+ children.push(makeElement(doc, 'p', { className: 'bb-minutes-participant-notice', textContent: current.notice, attrs: { role: 'status' } }));
1170
+ }
1171
+ if (current.openName && value.participants.some((participant) => participant.state === 'unconfirmed' && participant.displayName === current.openName)) {
1172
+ children.push(renderParticipantPanel(meeting, current));
1173
+ }
1174
+ slot.replaceChildren(...children);
1175
+ }
1176
+
1177
+ function refreshParticipants(meeting) {
1178
+ if (participantsSlot && state.detail?.meeting?.meeting_id === meeting.meeting_id) fillParticipants(participantsSlot, meeting);
1179
+ }
1180
+
1181
+ async function loadParticipants(detail, isCurrentRequest, { notice = '' } = {}) {
1182
+ const meetingId = detail.meeting.meeting_id;
1183
+ const previous = currentParticipants(detail.meeting);
1184
+ state.participants = { meetingId, value: previous?.value ?? null, openName: null, notice };
1185
+ let value;
1186
+ try {
1187
+ value = normalizeParticipants(await meetingParticipants.read({
1188
+ meeting: detail.meeting,
1189
+ minutes: detail.minutes,
1190
+ versions: detail.versions,
1191
+ }));
1192
+ } catch {
1193
+ value = { status: 'unavailable' };
1194
+ }
1195
+ if (!isCurrentRequest() || state.participants?.meetingId !== meetingId) return;
1196
+ state.participants = { ...state.participants, value };
1197
+ refreshParticipants(detail.meeting);
1198
+ }
1199
+
1200
+ function reloadParticipants(meeting, notice) {
1201
+ const detail = state.detail;
1202
+ if (!detail || detail.meeting?.meeting_id !== meeting.meeting_id) return Promise.resolve();
1203
+ return loadParticipants(detail, () => state.detail?.meeting?.meeting_id === meeting.meeting_id, { notice });
1204
+ }
1205
+
1206
+ function toggleParticipantPanel(meeting, displayName) {
1207
+ const current = currentParticipants(meeting);
1208
+ if (!current) return;
1209
+ const closing = current.openName === displayName;
1210
+ state.participants = { ...current, openName: closing ? null : displayName, panel: closing ? null : { status: 'loading' }, notice: '' };
1211
+ refreshParticipants(meeting);
1212
+ if (!closing) void loadCandidates(meeting, displayName);
1213
+ }
1214
+
1215
+ async function loadCandidates(meeting, displayName) {
1216
+ let panel;
1217
+ try {
1218
+ if (typeof meetingParticipants.candidates !== 'function') throw new Error('候補を読み込めませんでした');
1219
+ const value = await meetingParticipants.candidates({ meeting }, displayName);
1220
+ if (!isRecord(value)) throw new Error('候補を読み込めませんでした');
1221
+ const others = Number(value.other_meetings);
1222
+ panel = {
1223
+ status: 'ready',
1224
+ people: normalizeCandidateList(value.people, 'entity_id'),
1225
+ contacts: normalizeCandidateList(value.contacts, 'contact_id'),
1226
+ companies: normalizeCandidateList(value.companies, 'entity_id'),
1227
+ otherMeetings: Number.isInteger(others) && others > 0 ? others : 0,
1228
+ };
1229
+ } catch (error) {
1230
+ panel = { status: 'error', message: participantError(error, '候補を読み込めませんでした') };
1231
+ }
1232
+ const current = currentParticipants(meeting);
1233
+ if (!current || current.openName !== displayName) return;
1234
+ state.participants = { ...current, panel: { ...panel, name: current.panel?.name ?? displayName } };
1235
+ refreshParticipants(meeting);
1236
+ }
1237
+
1238
+ function candidateRow(name, evidence, actionLabel, onClick, busy) {
1239
+ const item = makeElement(doc, 'li', { className: 'bb-minutes-participant-candidate' });
1240
+ const body = makeElement(doc, 'div');
1241
+ body.append(makeElement(doc, 'strong', { textContent: name }));
1242
+ if (evidence.length) body.append(makeElement(doc, 'span', { className: 'bb-minutes-connection-detail', textContent: evidence.join('・') }));
1243
+ item.append(body, button(doc, actionLabel, onClick, { disabled: busy }));
1244
+ return item;
1245
+ }
1246
+
1247
+ function renderParticipantPanel(meeting, current) {
1248
+ const displayName = current.openName;
1249
+ const panelState = current.panel ?? { status: 'loading' };
1250
+ const busy = panelState.busy === true;
1251
+ const panel = makeElement(doc, 'div', {
1252
+ className: 'bb-minutes-participant-panel',
1253
+ attrs: { role: 'group', 'aria-label': `${displayName}を人物に結ぶ` },
1254
+ });
1255
+ panel.append(makeElement(doc, 'p', { className: 'bb-minutes-participant-panel-title', textContent: `「${displayName}」は誰ですか` }));
1256
+ if (panelState.status === 'loading') {
1257
+ panel.append(makeElement(doc, 'p', { className: 'bb-minutes-connection-note', textContent: '候補を読み込んでいます' }));
1258
+ } else if (panelState.status === 'error' && !panelState.people) {
1259
+ panel.append(makeElement(doc, 'p', { className: 'bb-minutes-connection-note is-unavailable', textContent: panelState.message, attrs: { role: 'alert' } }));
1260
+ }
1261
+ if (panelState.people) {
1262
+ if (panelState.otherMeetings > 0) {
1263
+ panel.append(makeElement(doc, 'p', {
1264
+ className: 'bb-minutes-participant-others',
1265
+ textContent: `同じ表示名の議事録があと${panelState.otherMeetings}件あります。結ぶとそれらも作り直します。`,
1266
+ }));
1267
+ }
1268
+ const list = makeElement(doc, 'ul', { className: 'bb-minutes-participant-candidates' });
1269
+ for (const person of panelState.people) {
1270
+ list.append(candidateRow(person.name, person.evidence, 'この人', () => void linkParticipant(meeting, displayName, { action: 'person', entity_id: person.id }), busy));
1271
+ }
1272
+ for (const contact of panelState.contacts) {
1273
+ const evidence = contact.evidence.length ? contact.evidence : contact.company ? [`名刺:${contact.company}`] : ['名刺'];
1274
+ list.append(candidateRow(contact.name, evidence, 'この名刺の名前で登録', () => void linkParticipant(meeting, displayName, { action: 'new_person', name: contact.name, contact_id: contact.id }), busy));
1275
+ }
1276
+ if (!panelState.people.length && !panelState.contacts.length) {
1277
+ panel.append(makeElement(doc, 'p', { className: 'bb-minutes-connection-note', textContent: '人物と名刺に、それらしい候補はありませんでした。' }));
1278
+ } else {
1279
+ panel.append(list);
1280
+ }
1281
+ const form = makeElement(doc, 'div', { className: 'bb-minutes-participant-new' });
1282
+ const field = labelInput(doc, '新しい人として登録する名前', { value: panelState.name ?? displayName, name: 'participant_name' });
1283
+ field.input.addEventListener('input', () => {
1284
+ const latest = currentParticipants(meeting);
1285
+ if (latest?.panel) latest.panel.name = field.input.value;
1286
+ });
1287
+ form.append(field.wrapper, button(doc, '新しい人として登録', () => void linkParticipant(meeting, displayName, {
1288
+ action: 'new_person',
1289
+ name: text(field.input.value, displayName),
1290
+ }), { disabled: busy, className: 'bb-minutes-button is-primary' }));
1291
+ panel.append(form);
1292
+ const notPerson = makeElement(doc, 'div', { className: 'bb-minutes-participant-not-person' });
1293
+ notPerson.append(makeElement(doc, 'span', { className: 'bb-minutes-connection-label', textContent: '人ではない' }));
1294
+ for (const company of panelState.companies) {
1295
+ notPerson.append(button(doc, `${company.name}(会社)に結ぶ`, () => void linkParticipant(meeting, displayName, { action: 'company', entity_id: company.id }), { disabled: busy }));
1296
+ }
1297
+ notPerson.append(button(doc, '結ばない', () => void linkParticipant(meeting, displayName, { action: 'not_person' }), { disabled: busy }));
1298
+ panel.append(notPerson);
1299
+ if (panelState.message) {
1300
+ panel.append(makeElement(doc, 'p', { className: 'bb-minutes-connection-note is-unavailable', textContent: panelState.message, attrs: { role: 'alert' } }));
1301
+ }
1302
+ if (panelState.existing) {
1303
+ panel.append(candidateRow(panelState.existing.name, ['同じ名前の人がいます'], 'この人', () => void linkParticipant(meeting, displayName, { action: 'person', entity_id: panelState.existing.id }), busy));
1304
+ }
1305
+ }
1306
+ panel.append(button(doc, 'あとで', () => toggleParticipantPanel(meeting, displayName), { className: 'bb-minutes-button is-quiet' }));
1307
+ return panel;
1308
+ }
1309
+
1310
+ function setPanel(meeting, displayName, patch) {
1311
+ const current = currentParticipants(meeting);
1312
+ if (!current || current.openName !== displayName) return false;
1313
+ state.participants = { ...current, panel: { ...current.panel, ...patch } };
1314
+ refreshParticipants(meeting);
1315
+ return true;
1316
+ }
1317
+
1318
+ function redraftNotice(result, done) {
1319
+ const count = Number(result?.redraft?.meetings);
1320
+ return Number.isInteger(count) && count > 0
1321
+ ? `${done}議事録${count}件を作り直します(数分かかります)。`
1322
+ : `${done}`;
1323
+ }
1324
+
1325
+ async function linkParticipant(meeting, displayName, input) {
1326
+ if (typeof meetingParticipants.link !== 'function') return;
1327
+ if (!setPanel(meeting, displayName, { busy: true, message: '', existing: null })) return;
1328
+ try {
1329
+ const result = await meetingParticipants.link({ meeting }, { display_name: displayName, ...input });
1330
+ const linkedName = text(result?.linked?.name);
1331
+ await reloadParticipants(meeting, redraftNotice(result, linkedName ? `「${displayName}」を${linkedName}に結びました。` : `「${displayName}」を記録しました。`));
1332
+ } catch (error) {
1333
+ const existing = isRecord(error?.candidate) && text(error.candidate.entity_id) && text(error.candidate.name)
1334
+ ? { id: text(error.candidate.entity_id), name: text(error.candidate.name) }
1335
+ : null;
1336
+ setPanel(meeting, displayName, {
1337
+ busy: false,
1338
+ existing,
1339
+ message: existing ? '同じ名前の人がいます。この人で良ければ選んでください。' : participantError(error, '結べませんでした。もう一度試してください。'),
1340
+ });
1341
+ }
1342
+ }
1343
+
1344
+ async function unlinkParticipant(meeting, participant) {
1345
+ try {
1346
+ const result = await meetingParticipants.unlink({ meeting }, {
1347
+ display_name: participant.displayName,
1348
+ ...(participant.entityId ? { entity_id: participant.entityId } : {}),
1349
+ });
1350
+ await reloadParticipants(meeting, redraftNotice(result, `「${participant.displayName}」の結び付きを取り消しました。`));
1351
+ } catch (error) {
1352
+ const current = currentParticipants(meeting);
1353
+ if (!current) return;
1354
+ state.participants = { ...current, notice: participantError(error, '取り消せませんでした。') };
1355
+ refreshParticipants(meeting);
1356
+ }
1357
+ }
1358
+
1055
1359
  function appendLoadError(parent, message, retry) {
1056
1360
  const notice = makeElement(doc, 'div', {
1057
1361
  className: 'bb-minutes-load-error',
@@ -1448,6 +1752,7 @@ export function createMeetingMinutesUI({
1448
1752
  function renderDetail(detail, documentRecord = null, selectedVersion = null) {
1449
1753
  clear(detailPane);
1450
1754
  connectionsSlot = null;
1755
+ participantsSlot = null;
1451
1756
  if (state.detailError) {
1452
1757
  const retry = state.meetingId === null ? loadMeetings : () => loadDetail(state.meetingId);
1453
1758
  appendLoadError(detailPane, state.detailError.startsWith('会議詳細を読み込めませんでした。')
@@ -1480,6 +1785,15 @@ export function createMeetingMinutesUI({
1480
1785
  textContent: participants ? `${when} / ${participants}` : when,
1481
1786
  }));
1482
1787
  detailPane.append(header);
1788
+ participantsSlot = null;
1789
+ if (hasParticipants()) {
1790
+ participantsSlot = makeElement(doc, 'section', {
1791
+ className: 'bb-minutes-participants',
1792
+ attrs: { 'aria-label': '参加者' },
1793
+ });
1794
+ fillParticipants(participantsSlot, meeting);
1795
+ detailPane.append(participantsSlot);
1796
+ }
1483
1797
  if (hasConnections()) {
1484
1798
  connectionsSlot = makeElement(doc, 'section', {
1485
1799
  className: 'bb-minutes-connections',
@@ -1631,9 +1945,11 @@ export function createMeetingMinutesUI({
1631
1945
  state.detail = detail;
1632
1946
  state.detailError = null;
1633
1947
  if (hasConnections()) state.connections = { meetingId, rows: null };
1948
+ if (hasParticipants() && state.participants?.meetingId !== meetingId) state.participants = null;
1634
1949
  renderDetail(detail);
1635
1950
  setStatus('会議詳細を読み込みました。');
1636
1951
  if (hasConnections()) void loadConnections(detail, isCurrentRequest);
1952
+ if (hasParticipants()) void loadParticipants(detail, isCurrentRequest);
1637
1953
  } catch (error) {
1638
1954
  if (!isCurrentRequest()) return state.detail;
1639
1955
  const preserved = preserveLoadedStateOnFailure(error) && state.detail?.meeting?.meeting_id === meetingId