@unson/brainbase-mcp 0.41.8 → 0.41.10

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.
@@ -153,6 +153,46 @@ function httpUrl(value) {
153
153
  }
154
154
  }
155
155
 
156
+ // Only http(s) URLs and same-site absolute paths become links.
157
+ function connectionHref(value) {
158
+ const raw = text(value);
159
+ if (raw.startsWith('/') && !raw.startsWith('//') && !raw.startsWith('/\\')) return raw;
160
+ return httpUrl(raw);
161
+ }
162
+
163
+ const CONNECTION_ROWS = Object.freeze([
164
+ ['project', 'プロジェクト'],
165
+ ['tasks', 'タスク'],
166
+ ['decisions', '判断'],
167
+ ['stored', '保存先'],
168
+ ['shared', '共有先'],
169
+ ]);
170
+
171
+ // A host answer is trusted only for what it states. Anything missing or
172
+ // malformed reads as unrecorded, never as a confirmed empty list (P9).
173
+ function normalizeConnectionRow(row) {
174
+ if (!isRecord(row)) return { status: 'unrecorded', reason: '' };
175
+ const reason = text(row.reason);
176
+ if (row.status === 'none') return { status: 'none', reason: '' };
177
+ if (row.status === 'unavailable') return { status: 'unavailable', reason };
178
+ if (row.status === 'recorded') {
179
+ const items = (Array.isArray(row.items) ? row.items : [])
180
+ .filter(isRecord)
181
+ .map((item) => ({ label: text(item.label), href: connectionHref(item.href), detail: text(item.detail) }))
182
+ .filter((item) => item.label);
183
+ if (items.length) return { status: 'recorded', items };
184
+ }
185
+ return { status: 'unrecorded', reason };
186
+ }
187
+
188
+ function normalizeConnections(value) {
189
+ const unreadable = !isRecord(value);
190
+ return Object.fromEntries(CONNECTION_ROWS.map(([key]) => [
191
+ key,
192
+ unreadable ? { status: 'unavailable', reason: '' } : normalizeConnectionRow(value[key]),
193
+ ]));
194
+ }
195
+
156
196
  function firstDate(values) {
157
197
  for (const value of values) {
158
198
  const date = parseDate(value);
@@ -294,9 +334,24 @@ function sourceLabel(source) {
294
334
  return `${provider} / ${locator} / ${revision}`;
295
335
  }
296
336
 
297
- function renderMarkdown(doc, body) {
337
+ function sameTitle(left, right) {
338
+ const normalize = (value) => String(value ?? '').normalize('NFKC').replace(/\s+/gu, '');
339
+ const a = normalize(left);
340
+ return a !== '' && a === normalize(right);
341
+ }
342
+
343
+ // The meeting title is already the detail heading; a body that opens with the same
344
+ // heading would show it twice (story-meeting-minutes-edit-and-withdraw-ui-v1 AC-07).
345
+ function withoutRepeatedTitle(lines, title) {
346
+ const first = lines.findIndex((line) => line.trim() !== '');
347
+ if (first < 0) return lines;
348
+ const heading = /^#{1,6}\s+(.+)$/u.exec(lines[first]);
349
+ return heading && sameTitle(heading[1], title) ? lines.slice(first + 1) : lines;
350
+ }
351
+
352
+ function renderMarkdown(doc, body, { title } = {}) {
298
353
  const root = makeElement(doc, 'div', { className: 'bb-minutes-markdown' });
299
- const lines = String(body).replace(/\r\n/g, '\n').split('\n');
354
+ const lines = withoutRepeatedTitle(String(body).replace(/\r\n/g, '\n').split('\n'), title);
300
355
  let list = null;
301
356
  let listKind = null;
302
357
  const closeList = () => {
@@ -342,12 +397,12 @@ function renderMarkdown(doc, body) {
342
397
  return root;
343
398
  }
344
399
 
345
- function versionContent(doc, version) {
400
+ function versionContent(doc, version, { title } = {}) {
346
401
  if (!isRecord(version)) return makeElement(doc, 'p', { textContent: '版の形式を確認できません。', className: 'bb-minutes-warning' });
347
402
  if (isRecord(version.source_ref)) {
348
403
  return makeElement(doc, 'p', { className: 'bb-minutes-source', textContent: `本文は元の記録にあります。${sourceLabel(version.source_ref)}` });
349
404
  }
350
- if (typeof version.body === 'string') return renderMarkdown(doc, version.body);
405
+ if (typeof version.body === 'string') return renderMarkdown(doc, version.body, { title });
351
406
  return makeElement(doc, 'p', { className: 'bb-minutes-warning', textContent: '本文または外部参照を確認できません。' });
352
407
  }
353
408
 
@@ -513,8 +568,10 @@ export function createMeetingMinutesUI({
513
568
  basePath = '/api/meeting-minutes',
514
569
  versionActionExtensions = [],
515
570
  meetingLabel,
571
+ meetingConnections,
516
572
  canCreateMeeting,
517
573
  canEditMinutes,
574
+ currentPrincipalId,
518
575
  now = () => new Date(),
519
576
  } = {}) {
520
577
  if (!root) throw new TypeError('root is required');
@@ -533,9 +590,17 @@ export function createMeetingMinutesUI({
533
590
  loading: false,
534
591
  listError: null,
535
592
  detailError: null,
593
+ // Which document is being edited or about to be withdrawn; both reset on every detail load.
594
+ editingMinutesId: null,
595
+ withdrawingMinutesId: null,
596
+ // { meetingId, rows } where rows is null while the host is answering.
597
+ connections: null,
536
598
  };
537
599
  let listRequestGeneration = 0;
538
600
  let detailRequestGeneration = 0;
601
+ let connectionsSlot = null;
602
+ let railProjectSlot = null;
603
+ let railProjectMeeting = null;
539
604
 
540
605
  const screen = makeElement(doc, 'div', { className: 'bb-minutes-screen', attrs: { 'data-contract-version': MEETING_MINUTES_UI_CONTRACT_VERSION } });
541
606
  const heading = makeElement(doc, 'header', { className: 'bb-minutes-heading' });
@@ -619,6 +684,21 @@ export function createMeetingMinutesUI({
619
684
  return canEditMinutes !== false;
620
685
  }
621
686
 
687
+ function viewerId() {
688
+ try {
689
+ const value = typeof currentPrincipalId === 'function' ? currentPrincipalId() : currentPrincipalId;
690
+ return text(value);
691
+ } catch {
692
+ return '';
693
+ }
694
+ }
695
+
696
+ // The server decides; the screen only hides the button from people it already knows it will refuse.
697
+ function mayWithdraw(documentRecord) {
698
+ const viewer = viewerId();
699
+ return !viewer || viewer === text(documentRecord?.created_actor_id);
700
+ }
701
+
622
702
  function stateChip(versionStatus, { detailed = false } = {}) {
623
703
  const label = detailed && versionStatus.detail ? versionStatus.detail : versionStatus.label;
624
704
  return makeElement(doc, 'span', {
@@ -632,28 +712,122 @@ export function createMeetingMinutesUI({
632
712
  line.append(node);
633
713
  }
634
714
 
715
+ // One gray notice per detail, only when something cannot be done here.
635
716
  function appendReadOnlyNotice(parent, version) {
636
717
  const source = isRecord(version?.source_ref) ? version.source_ref : null;
718
+ const editable = canEdit();
719
+ if (editable && !source) return false;
637
720
  const url = httpUrl(source?.locator);
638
721
  const provider = text(source?.provider, '元の記録');
639
722
  const notice = makeElement(doc, 'div', { className: 'bb-minutes-edit-guard is-info' });
640
- notice.append(makeElement(doc, 'p', {
641
- textContent: canEdit()
642
- ? 'この画面では読むことと確認だけができます。'
643
- : 'この画面では読むことだけができます。確認はプロジェクトの編集権限がある人が行います。',
644
- }));
723
+ if (!editable) {
724
+ notice.append(makeElement(doc, 'p', {
725
+ textContent: 'この画面では読むことだけができます。確認はプロジェクトの編集権限がある人が行います。',
726
+ }));
727
+ }
645
728
  if (url) {
646
- const line = makeElement(doc, 'p', { textContent: '本文を直すときは、元の記録で編集してください。' });
729
+ const line = makeElement(doc, 'p', { textContent: '本文は元の記録にあります。本文を直すときは、元の記録で編集してください。' });
647
730
  line.append(makeElement(doc, 'a', {
648
731
  className: 'bb-minutes-source-link',
649
732
  textContent: `元の記録を開く(${provider})`,
650
733
  attrs: { href: url, target: '_blank', rel: 'noopener noreferrer' },
651
734
  }));
652
735
  notice.append(line);
653
- } else {
736
+ } else if (source) {
654
737
  notice.append(makeElement(doc, 'p', { textContent: '本文を直すときは、議事録を取り込んだ元で編集してください。' }));
738
+ } else {
739
+ notice.append(makeElement(doc, 'p', { textContent: '本文を直すのも、プロジェクトの編集権限がある人です。' }));
655
740
  }
656
741
  parent.append(notice);
742
+ return true;
743
+ }
744
+
745
+ function hasConnections() {
746
+ return typeof meetingConnections === 'function';
747
+ }
748
+
749
+ function currentConnections(meeting) {
750
+ const value = state.connections;
751
+ return value && value.meetingId === meeting?.meeting_id ? value.rows : null;
752
+ }
753
+
754
+ function connectionLink(item, className) {
755
+ if (!item.href) return makeElement(doc, 'span', { className, textContent: item.label });
756
+ const external = /^https?:/iu.test(item.href);
757
+ return makeElement(doc, 'a', {
758
+ className,
759
+ textContent: item.label,
760
+ attrs: {
761
+ href: item.href,
762
+ target: external ? '_blank' : undefined,
763
+ rel: external ? 'noopener noreferrer' : undefined,
764
+ },
765
+ });
766
+ }
767
+
768
+ function connectionValue(row) {
769
+ if (!row) return makeElement(doc, 'p', { className: 'bb-minutes-connection-note', textContent: '読み込んでいます' });
770
+ if (row.status === 'recorded') {
771
+ const list = makeElement(doc, 'ul', { className: 'bb-minutes-connection-items' });
772
+ for (const item of row.items) {
773
+ const entry = makeElement(doc, 'li');
774
+ entry.append(connectionLink(item, 'bb-minutes-connection-link'));
775
+ if (item.detail) entry.append(makeElement(doc, 'span', { className: 'bb-minutes-connection-detail', textContent: item.detail }));
776
+ list.append(entry);
777
+ }
778
+ return list;
779
+ }
780
+ const label = row.status === 'none' ? 'なし' : row.status === 'unavailable' ? '読み込めません' : '記録なし';
781
+ return makeElement(doc, 'p', {
782
+ className: `bb-minutes-connection-note is-${row.status}`,
783
+ textContent: row.reason ? `${label}(${row.reason})` : label,
784
+ });
785
+ }
786
+
787
+ function fillConnections(slot, rows) {
788
+ slot.replaceChildren(makeElement(doc, 'h3', { className: 'bb-minutes-connections-title', textContent: 'この会議から' }));
789
+ const list = makeElement(doc, 'div', { className: 'bb-minutes-connection-list' });
790
+ for (const [key, label] of CONNECTION_ROWS) {
791
+ const row = makeElement(doc, 'div', { className: 'bb-minutes-connection-row', attrs: { 'data-connection': key } });
792
+ row.append(
793
+ makeElement(doc, 'span', { className: 'bb-minutes-connection-label', textContent: label }),
794
+ connectionValue(rows ? rows[key] : null),
795
+ );
796
+ list.append(row);
797
+ }
798
+ slot.append(list);
799
+ }
800
+
801
+ function fillRailProject(slot, meeting) {
802
+ const project = currentConnections(meeting)?.project;
803
+ const item = project?.status === 'recorded' ? project.items.find((candidate) => candidate.href) : null;
804
+ if (item) {
805
+ slot.textContent = '';
806
+ slot.replaceChildren(connectionLink(item, 'bb-minutes-source-link'));
807
+ return;
808
+ }
809
+ const label = meetingLabelText(meetingLabel, meeting);
810
+ slot.replaceChildren();
811
+ slot.textContent = label || 'プロジェクトは記録されていません';
812
+ }
813
+
814
+ async function loadConnections(detail, isCurrentRequest) {
815
+ const meetingId = detail.meeting.meeting_id;
816
+ state.connections = { meetingId, rows: null };
817
+ let rows;
818
+ try {
819
+ rows = normalizeConnections(await meetingConnections({
820
+ meeting: detail.meeting,
821
+ minutes: detail.minutes,
822
+ versions: detail.versions,
823
+ }));
824
+ } catch {
825
+ rows = normalizeConnections(null);
826
+ }
827
+ if (!isCurrentRequest() || state.connections?.meetingId !== meetingId) return;
828
+ state.connections = { meetingId, rows };
829
+ if (connectionsSlot) fillConnections(connectionsSlot, rows);
830
+ if (railProjectSlot && railProjectMeeting?.meeting_id === meetingId) fillRailProject(railProjectSlot, railProjectMeeting);
657
831
  }
658
832
 
659
833
  function appendLoadError(parent, message, retry) {
@@ -753,6 +927,8 @@ export function createMeetingMinutesUI({
753
927
  function renderRail(version, documentRecord, meeting) {
754
928
  if (!rail) return;
755
929
  clear(rail);
930
+ railProjectSlot = null;
931
+ railProjectMeeting = null;
756
932
  const block = makeElement(doc, 'section', { className: 'bb-minutes-rail-block' });
757
933
  block.append(makeElement(doc, 'h2', { textContent: '関連' }));
758
934
  if (!meeting && !version) {
@@ -763,8 +939,10 @@ export function createMeetingMinutesUI({
763
939
  block.append(makeElement(doc, 'h3', { textContent: '予定' }));
764
940
  block.append(makeElement(doc, 'p', { className: 'bb-minutes-rail-note', textContent: '予定とのつながりは未対応です。' }));
765
941
  block.append(makeElement(doc, 'h3', { textContent: 'プロジェクト' }));
766
- const label = meetingLabelText(meetingLabel, meeting);
767
- block.append(makeElement(doc, 'p', { textContent: label || 'プロジェクトは記録されていません' }));
942
+ railProjectSlot = makeElement(doc, 'p', { className: 'bb-minutes-rail-project' });
943
+ railProjectMeeting = meeting;
944
+ fillRailProject(railProjectSlot, meeting);
945
+ block.append(railProjectSlot);
768
946
  appendIdentifiers(doc, block, {
769
947
  meetingId: meeting?.meeting_id,
770
948
  minutesId: documentRecord?.minutes_id,
@@ -773,6 +951,11 @@ export function createMeetingMinutesUI({
773
951
  rail.append(block);
774
952
  }
775
953
 
954
+ function rerender(detail, documentRecord, version) {
955
+ clear(detailPane);
956
+ renderDetail(detail, documentRecord, version);
957
+ }
958
+
776
959
  function renderVersion(detail, documentRecord, selectedVersion, noticeState) {
777
960
  const versions = detailVersions(detail, documentRecord.minutes_id);
778
961
  const latest = versions.find((version) => version.version_id === documentRecord.current_version_id) ?? versions[versions.length - 1] ?? null;
@@ -782,105 +965,143 @@ export function createMeetingMinutesUI({
782
965
  const selectedState = versionState(selected, versions, latest);
783
966
  const latestState = versionState(latest, versions, latest);
784
967
  const viewingOld = Boolean(selected && latest && selected.version_id !== latest.version_id);
968
+ const withdrawal = isRecord(documentRecord.withdrawal) ? documentRecord.withdrawal : null;
969
+ const native = isRecord(selected) && typeof selected.body === 'string' && !isRecord(selected.source_ref);
970
+ const editing = state.editingMinutesId === documentRecord.minutes_id && native && !viewingOld && !withdrawal && canEdit();
785
971
  const section = makeElement(doc, 'section', { className: 'bb-minutes-document' });
972
+ if (withdrawal) {
973
+ const when = formatStamp(withdrawal.withdrawn_at);
974
+ const who = text(withdrawal.actor_id);
975
+ section.append(makeElement(doc, 'p', {
976
+ className: 'bb-minutes-withdrawn',
977
+ textContent: `取り下げ済み(${[who, when].filter(Boolean).join('・') || '取り下げた人と日時は未記録'})。プロジェクトの議事録の一覧には出ません。`,
978
+ }));
979
+ }
786
980
  if (viewingOld) {
787
981
  const banner = makeElement(doc, 'div', { className: 'bb-minutes-old-version' });
788
982
  const latestName = latestState.code === 'confirmed' ? '確認済み' : latestState.label;
789
983
  banner.append(makeElement(doc, 'p', {
790
984
  textContent: `古い版を表示しています。最新は${versionLabel(versions, latest)}(${latestName})です。`,
791
985
  }));
792
- banner.append(button(doc, '最新の版を見る', () => {
793
- clear(detailPane);
794
- renderDetail(detail, documentRecord, latest);
795
- }));
986
+ banner.append(button(doc, '最新の版を見る', () => rerender(detail, documentRecord, latest)));
796
987
  section.append(banner);
797
988
  }
798
- const band = makeElement(doc, 'div', { className: 'bb-minutes-version-band' });
799
- const latestMark = selected && latest && selected.version_id === latest.version_id ? '(最新)' : '';
800
- band.append(makeElement(doc, 'strong', { textContent: `${selectedLabel}${latestMark}` }));
801
- const created = formatStamp(selected?.created_at);
802
- if (created) band.append(makeElement(doc, 'span', { className: 'bb-minutes-item-meta', textContent: created }));
803
- band.append(stateChip(selectedState, { detailed: true }));
804
- const history = makeElement(doc, 'div', { className: 'bb-minutes-version-history is-collapsed', attrs: { 'aria-label': '版の履歴' } });
805
- const historyButton = button(doc, `版の履歴(${versions.length}件)`, () => {
806
- const collapsed = history.className.includes('is-collapsed');
807
- history.className = collapsed ? 'bb-minutes-version-history' : 'bb-minutes-version-history is-collapsed';
808
- historyButton.textContent = collapsed ? `版の履歴を閉じる(${versions.length}件)` : `版の履歴(${versions.length}件)`;
809
- });
810
- band.append(historyButton);
811
- section.append(band);
812
- if (!noticeState.placed) {
813
- noticeState.placed = true;
814
- appendReadOnlyNotice(section, selected);
989
+ // State and actions come first; which version this is goes below the body.
990
+ const actions = makeElement(doc, 'div', { className: 'bb-minutes-version-actions' });
991
+ actions.append(stateChip(selectedState, { detailed: true }));
992
+ if (!withdrawal && !editing) {
993
+ if (selected && canEdit() && !isRecord(selected.confirmation) && selectedState.code !== 'unknown' && selectedState.code !== 'creating') {
994
+ const confirm = button(doc, 'この版を確認済みにする', async () => {
995
+ if (!canEdit()) {
996
+ rerender(detail, documentRecord, selected);
997
+ return;
998
+ }
999
+ confirm.disabled = true;
1000
+ try {
1001
+ await mutate(`/${encodeURIComponent(detail.meeting.meeting_id)}/minutes/${encodeURIComponent(documentRecord.minutes_id)}/confirm`, {
1002
+ version_id: selected.version_id,
1003
+ expected_revision: documentRecord.revision,
1004
+ });
1005
+ await loadDetail(detail.meeting.meeting_id);
1006
+ if (!state.detailError) setStatus(`${selectedLabel}を確認済みにしました。`, 'success');
1007
+ } catch (error) {
1008
+ confirm.disabled = false;
1009
+ setStatus(error instanceof Error ? error.message : '確認を保存できませんでした。', 'error');
1010
+ }
1011
+ }, { className: 'bb-minutes-button is-primary' });
1012
+ actions.append(confirm);
1013
+ }
1014
+ if (native && !viewingOld && canEdit()) {
1015
+ actions.append(button(doc, '編集', () => {
1016
+ state.editingMinutesId = documentRecord.minutes_id;
1017
+ state.withdrawingMinutesId = null;
1018
+ rerender(detail, documentRecord, selected);
1019
+ }));
1020
+ }
1021
+ if (canEdit() && mayWithdraw(documentRecord)) {
1022
+ actions.append(button(doc, '取り下げる', () => {
1023
+ state.withdrawingMinutesId = documentRecord.minutes_id;
1024
+ state.editingMinutesId = null;
1025
+ rerender(detail, documentRecord, selected);
1026
+ }, { className: 'bb-minutes-button is-quiet' }));
1027
+ }
815
1028
  }
816
- for (const version of [...versions].reverse()) {
817
- const versionButton = makeElement(doc, 'button', {
818
- className: `bb-minutes-version${version.version_id === selected?.version_id ? ' is-selected' : ''}`,
819
- type: 'button',
820
- attrs: { type: 'button' },
821
- });
822
- const itemState = versionState(version, versions, latest);
823
- const isLatest = Boolean(latest) && version.version_id === latest.version_id;
824
- const line1 = makeElement(doc, 'span', { className: 'bb-minutes-version-line' });
825
- line1.append(
826
- makeElement(doc, 'strong', { textContent: `${versionLabel(versions, version)}${isLatest ? '(最新)' : ''}` }),
827
- stateChip(itemState),
828
- makeElement(doc, 'span', { className: 'bb-minutes-version-stamp', textContent: historyStamp(version, clock()) }),
829
- );
830
- versionButton.append(
831
- line1,
832
- makeElement(doc, 'span', {
833
- className: 'bb-minutes-version-line is-secondary',
834
- textContent: historySecondLine(version, versions, latest),
835
- }),
836
- );
837
- versionButton.addEventListener('click', () => {
838
- clear(detailPane);
839
- renderDetail(detail, documentRecord, version);
840
- });
841
- history.append(versionButton);
1029
+ section.append(actions);
1030
+ if (!withdrawal && canEdit() && !mayWithdraw(documentRecord)) {
1031
+ section.append(makeElement(doc, 'p', { className: 'bb-minutes-withdraw-guard', textContent: '取り下げられるのは、この議事録を作った人だけです。' }));
842
1032
  }
843
- section.append(history);
1033
+ if (!withdrawal && state.withdrawingMinutesId === documentRecord.minutes_id && canEdit() && mayWithdraw(documentRecord)) {
1034
+ section.append(renderWithdrawConfirm(detail, documentRecord, selected));
1035
+ }
1036
+ if (!noticeState.placed && appendReadOnlyNotice(section, selected)) noticeState.placed = true;
844
1037
  const content = makeElement(doc, 'div', { className: 'bb-minutes-version-content' });
845
1038
  const bodyRoot = makeElement(doc, 'div', {
846
1039
  className: 'bb-minutes-external-body-slot',
847
1040
  attrs: { 'data-minutes-external-body': 'true' },
848
1041
  });
849
- content.append(versionContent(doc, selected), bodyRoot);
1042
+ if (editing) {
1043
+ content.append(renderEditForm(detail, documentRecord, selected, versions));
1044
+ } else {
1045
+ content.append(versionContent(doc, selected, { title: detail.meeting?.title }), bodyRoot);
1046
+ }
850
1047
  section.append(content);
851
- if (selected && canEdit() && !isRecord(selected.confirmation) && selectedState.code !== 'unknown' && selectedState.code !== 'creating') {
852
- const confirm = button(doc, 'この版を確認済みにする', async () => {
853
- if (!canEdit()) {
854
- clear(detailPane);
855
- renderDetail(detail, documentRecord, selected);
856
- return;
857
- }
858
- confirm.disabled = true;
859
- try {
860
- await mutate(`/${encodeURIComponent(detail.meeting.meeting_id)}/minutes/${encodeURIComponent(documentRecord.minutes_id)}/confirm`, {
861
- version_id: selected.version_id,
862
- expected_revision: documentRecord.revision,
863
- });
864
- await loadDetail(detail.meeting.meeting_id);
865
- if (!state.detailError) setStatus(`${selectedLabel}を確認済みにしました。`, 'success');
866
- } catch (error) {
867
- confirm.disabled = false;
868
- setStatus(error instanceof Error ? error.message : '確認を保存できませんでした。', 'error');
869
- }
870
- }, { className: 'bb-minutes-button is-primary' });
871
- band.append(confirm);
1048
+ const meta = makeElement(doc, 'div', { className: 'bb-minutes-version-meta' });
1049
+ const latestMark = selected && latest && selected.version_id === latest.version_id ? '(最新)' : '';
1050
+ meta.append(makeElement(doc, 'span', {
1051
+ className: 'bb-minutes-item-meta',
1052
+ textContent: [`${selectedLabel}${latestMark}`, historyStamp(selected, clock()), creatorFact(selected)].join('・'),
1053
+ }));
1054
+ section.append(meta);
1055
+ if (versions.length >= 2) {
1056
+ const history = makeElement(doc, 'div', { className: 'bb-minutes-version-history is-collapsed', attrs: { 'aria-label': '版の履歴' } });
1057
+ const historyButton = button(doc, `版の履歴(${versions.length}件)`, () => {
1058
+ const collapsed = history.className.includes('is-collapsed');
1059
+ history.className = collapsed ? 'bb-minutes-version-history' : 'bb-minutes-version-history is-collapsed';
1060
+ historyButton.textContent = collapsed ? `版の履歴を閉じる(${versions.length}件)` : `版の履歴(${versions.length}件)`;
1061
+ });
1062
+ meta.append(historyButton);
1063
+ for (const version of [...versions].reverse()) {
1064
+ const versionButton = makeElement(doc, 'button', {
1065
+ className: `bb-minutes-version${version.version_id === selected?.version_id ? ' is-selected' : ''}`,
1066
+ type: 'button',
1067
+ attrs: { type: 'button' },
1068
+ });
1069
+ const itemState = versionState(version, versions, latest);
1070
+ const isLatest = Boolean(latest) && version.version_id === latest.version_id;
1071
+ const line1 = makeElement(doc, 'span', { className: 'bb-minutes-version-line' });
1072
+ line1.append(
1073
+ makeElement(doc, 'strong', { textContent: `${versionLabel(versions, version)}${isLatest ? '(最新)' : ''}` }),
1074
+ stateChip(itemState),
1075
+ makeElement(doc, 'span', { className: 'bb-minutes-version-stamp', textContent: historyStamp(version, clock()) }),
1076
+ );
1077
+ versionButton.append(
1078
+ line1,
1079
+ makeElement(doc, 'span', {
1080
+ className: 'bb-minutes-version-line is-secondary',
1081
+ textContent: historySecondLine(version, versions, latest),
1082
+ }),
1083
+ );
1084
+ versionButton.addEventListener('click', () => {
1085
+ state.editingMinutesId = null;
1086
+ rerender(detail, documentRecord, version);
1087
+ });
1088
+ history.append(versionButton);
1089
+ }
1090
+ section.append(history);
1091
+ }
1092
+ if (!editing) {
1093
+ appendVersionActionExtensions(doc, section, {
1094
+ extensions: versionActionExtensions,
1095
+ detail,
1096
+ documentRecord,
1097
+ version: selected,
1098
+ request: call,
1099
+ mutate,
1100
+ refresh: () => loadDetail(detail.meeting.meeting_id),
1101
+ setStatus,
1102
+ bodyRoot,
1103
+ });
872
1104
  }
873
- appendVersionActionExtensions(doc, section, {
874
- extensions: versionActionExtensions,
875
- detail,
876
- documentRecord,
877
- version: selected,
878
- request: call,
879
- mutate,
880
- refresh: () => loadDetail(detail.meeting.meeting_id),
881
- setStatus,
882
- bodyRoot,
883
- });
884
1105
  appendIdentifiers(doc, section, {
885
1106
  meetingId: detail.meeting?.meeting_id,
886
1107
  minutesId: documentRecord.minutes_id,
@@ -890,8 +1111,97 @@ export function createMeetingMinutesUI({
890
1111
  return section;
891
1112
  }
892
1113
 
1114
+ // Editing writes a new version; the earlier versions stay (story-meeting-minutes-edit-and-withdraw-ui-v1 AC-01).
1115
+ function renderEditForm(detail, documentRecord, selected, versions) {
1116
+ const form = makeElement(doc, 'form', { className: 'bb-minutes-edit-form' });
1117
+ const field = labelInput(doc, '本文', { type: 'textarea', name: 'body', value: selected.body });
1118
+ field.input.value = selected.body;
1119
+ field.input.rows = 16;
1120
+ form.append(
1121
+ field.wrapper,
1122
+ makeElement(doc, 'p', { className: 'bb-minutes-edit-note', textContent: `保存すると第${versions.length + 1}版になります。前の版は残ります。` }),
1123
+ makeElement(doc, 'p', { className: 'bb-minutes-edit-note', textContent: '公開したときにGitへ書き出した分とSlackの知らせは、前の版のまま変わりません。' }),
1124
+ );
1125
+ const save = makeElement(doc, 'button', { className: 'bb-minutes-button is-primary', textContent: '新しい版として保存', type: 'submit' });
1126
+ const cancel = button(doc, 'やめる', () => {
1127
+ state.editingMinutesId = null;
1128
+ rerender(detail, documentRecord, selected);
1129
+ });
1130
+ const row = makeElement(doc, 'div', { className: 'bb-minutes-actions' });
1131
+ row.append(save, cancel);
1132
+ form.append(row);
1133
+ form.addEventListener('submit', async (event) => {
1134
+ event.preventDefault();
1135
+ if (!canEdit()) {
1136
+ state.editingMinutesId = null;
1137
+ rerender(detail, documentRecord, selected);
1138
+ return;
1139
+ }
1140
+ save.disabled = true;
1141
+ try {
1142
+ await mutate(`/${encodeURIComponent(detail.meeting.meeting_id)}/minutes/${encodeURIComponent(documentRecord.minutes_id)}`, {
1143
+ body: field.input.value,
1144
+ expected_revision: documentRecord.revision,
1145
+ });
1146
+ state.editingMinutesId = null;
1147
+ await loadDetail(detail.meeting.meeting_id);
1148
+ if (!state.detailError) setStatus(`第${versions.length + 1}版として保存しました。確認待ちです。`, 'success');
1149
+ } catch (error) {
1150
+ save.disabled = false;
1151
+ setStatus(error instanceof Error ? error.message : '新しい版を保存できませんでした。', 'error');
1152
+ }
1153
+ });
1154
+ return form;
1155
+ }
1156
+
1157
+ // Withdrawal takes the document back without deleting it (台帳 D9, story-meeting-minutes-withdraw-v1).
1158
+ function renderWithdrawConfirm(detail, documentRecord, selected) {
1159
+ const panel = makeElement(doc, 'div', { className: 'bb-minutes-withdraw-confirm', attrs: { role: 'group', 'aria-label': '取り下げの確認' } });
1160
+ panel.append(makeElement(doc, 'p', { className: 'bb-minutes-withdraw-title', textContent: 'この議事録を取り下げますか?' }));
1161
+ const facts = makeElement(doc, 'ul', { className: 'bb-minutes-md-list' });
1162
+ for (const line of [
1163
+ 'プロジェクトの議事録の一覧から外れます。',
1164
+ '版と本文は消えず、取り下げた人と日時が残ります。',
1165
+ 'Gitへ書き出した分と、Slackで送った知らせは取り消せません。',
1166
+ '取り下げは元に戻せません。もう一度公開すると、新しい議事録として作られます。',
1167
+ ]) facts.append(makeElement(doc, 'li', { textContent: line }));
1168
+ panel.append(facts);
1169
+ const row = makeElement(doc, 'div', { className: 'bb-minutes-actions' });
1170
+ const confirm = button(doc, '取り下げる', async () => {
1171
+ if (!canEdit() || !mayWithdraw(documentRecord)) {
1172
+ state.withdrawingMinutesId = null;
1173
+ rerender(detail, documentRecord, selected);
1174
+ return;
1175
+ }
1176
+ confirm.disabled = true;
1177
+ try {
1178
+ await mutate(`/${encodeURIComponent(detail.meeting.meeting_id)}/minutes/${encodeURIComponent(documentRecord.minutes_id)}/withdraw`, {
1179
+ expected_revision: documentRecord.revision,
1180
+ });
1181
+ state.withdrawingMinutesId = null;
1182
+ await loadMeetings();
1183
+ await loadDetail(detail.meeting.meeting_id);
1184
+ if (!state.detailError) setStatus('議事録を取り下げました。', 'success');
1185
+ } catch (error) {
1186
+ confirm.disabled = false;
1187
+ const denied = error?.status === 403;
1188
+ setStatus(denied
1189
+ ? '取り下げられませんでした。取り下げられるのは、この議事録を作った人だけです。'
1190
+ : error instanceof Error ? error.message : '取り下げられませんでした。', 'error');
1191
+ }
1192
+ }, { className: 'bb-minutes-button is-danger' });
1193
+ const cancel = button(doc, 'やめる', () => {
1194
+ state.withdrawingMinutesId = null;
1195
+ rerender(detail, documentRecord, selected);
1196
+ });
1197
+ row.append(confirm, cancel);
1198
+ panel.append(row);
1199
+ return panel;
1200
+ }
1201
+
893
1202
  function renderDetail(detail, documentRecord = null, selectedVersion = null) {
894
1203
  clear(detailPane);
1204
+ connectionsSlot = null;
895
1205
  if (state.detailError) {
896
1206
  const retry = state.meetingId === null ? loadMeetings : () => loadDetail(state.meetingId);
897
1207
  appendLoadError(detailPane, state.detailError.startsWith('会議詳細を読み込めませんでした。')
@@ -924,6 +1234,14 @@ export function createMeetingMinutesUI({
924
1234
  textContent: participants ? `${when} / ${participants}` : when,
925
1235
  }));
926
1236
  detailPane.append(header);
1237
+ if (hasConnections()) {
1238
+ connectionsSlot = makeElement(doc, 'section', {
1239
+ className: 'bb-minutes-connections',
1240
+ attrs: { 'aria-label': 'この会議から' },
1241
+ });
1242
+ fillConnections(connectionsSlot, currentConnections(meeting));
1243
+ detailPane.append(connectionsSlot);
1244
+ }
927
1245
  const documents = Array.isArray(detail.minutes) ? detail.minutes : [];
928
1246
  const noticeState = { placed: false };
929
1247
  if (documents.length === 0) {
@@ -1058,6 +1376,8 @@ export function createMeetingMinutesUI({
1058
1376
  state.meetingId = meetingId;
1059
1377
  state.minutesId = null;
1060
1378
  state.detailError = null;
1379
+ state.editingMinutesId = null;
1380
+ state.withdrawingMinutesId = null;
1061
1381
  renderList();
1062
1382
  setStatus('会議詳細を読み込んでいます。');
1063
1383
  try {
@@ -1066,8 +1386,10 @@ export function createMeetingMinutesUI({
1066
1386
  if (!isRecord(detail) || !isRecord(detail.meeting) || !Array.isArray(detail.minutes) || !Array.isArray(detail.versions)) throw new Error('会議詳細の形式を確認できません。');
1067
1387
  state.detail = detail;
1068
1388
  state.detailError = null;
1389
+ if (hasConnections()) state.connections = { meetingId, rows: null };
1069
1390
  renderDetail(detail);
1070
1391
  setStatus('会議詳細を読み込みました。');
1392
+ if (hasConnections()) void loadConnections(detail, isCurrentRequest);
1071
1393
  } catch (error) {
1072
1394
  if (!isCurrentRequest()) return state.detail;
1073
1395
  const preserved = preserveLoadedStateOnFailure(error) && state.detail?.meeting?.meeting_id === meetingId