@unson/brainbase-mcp 0.41.9 → 0.41.11

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.
@@ -7,12 +7,27 @@
7
7
  *
8
8
  * List order and labels are a projection. Canonical records are not rewritten.
9
9
  * Calendar links stay out until a meeting-side event id is verified.
10
+ * Filters and sort only narrow the loaded list; the host owns any filter that
11
+ * changes what it asks the server for (story-meeting-minutes-list-filters-v1).
10
12
  */
11
13
 
14
+ import { workspaceCombobox } from './workspace-kit.js';
15
+
12
16
  export const MEETING_MINUTES_UI_CONTRACT_VERSION = 'brainbase.meeting-minutes-ui.v1';
13
17
 
14
18
  const WEEKDAYS = ['日', '月', '火', '水', '木', '金', '土'];
15
19
  const TOKYO = 'Asia/Tokyo';
20
+ const SORTS = Object.freeze([
21
+ { value: 'scheduled-desc', label: '会議の日時が新しい順' },
22
+ { value: 'scheduled-asc', label: '会議の日時が古い順' },
23
+ { value: 'updated-desc', label: '議事録の更新が新しい順' },
24
+ ]);
25
+ const STATE_FILTERS = Object.freeze([
26
+ { value: '', label: 'すべての状態' },
27
+ { value: 'pending', label: '確認待ち' },
28
+ { value: 'confirmed', label: '確認済み' },
29
+ ]);
30
+ const EMPTY_FILTERS = Object.freeze({ query: '', from: '', to: '', participant: '', status: '' });
16
31
 
17
32
  function tokyoParts(date) {
18
33
  const parts = new Intl.DateTimeFormat('en-US', {
@@ -153,6 +168,46 @@ function httpUrl(value) {
153
168
  }
154
169
  }
155
170
 
171
+ // Only http(s) URLs and same-site absolute paths become links.
172
+ function connectionHref(value) {
173
+ const raw = text(value);
174
+ if (raw.startsWith('/') && !raw.startsWith('//') && !raw.startsWith('/\\')) return raw;
175
+ return httpUrl(raw);
176
+ }
177
+
178
+ const CONNECTION_ROWS = Object.freeze([
179
+ ['project', 'プロジェクト'],
180
+ ['tasks', 'タスク'],
181
+ ['decisions', '判断'],
182
+ ['stored', '保存先'],
183
+ ['shared', '共有先'],
184
+ ]);
185
+
186
+ // A host answer is trusted only for what it states. Anything missing or
187
+ // malformed reads as unrecorded, never as a confirmed empty list (P9).
188
+ function normalizeConnectionRow(row) {
189
+ if (!isRecord(row)) return { status: 'unrecorded', reason: '' };
190
+ const reason = text(row.reason);
191
+ if (row.status === 'none') return { status: 'none', reason: '' };
192
+ if (row.status === 'unavailable') return { status: 'unavailable', reason };
193
+ if (row.status === 'recorded') {
194
+ const items = (Array.isArray(row.items) ? row.items : [])
195
+ .filter(isRecord)
196
+ .map((item) => ({ label: text(item.label), href: connectionHref(item.href), detail: text(item.detail) }))
197
+ .filter((item) => item.label);
198
+ if (items.length) return { status: 'recorded', items };
199
+ }
200
+ return { status: 'unrecorded', reason };
201
+ }
202
+
203
+ function normalizeConnections(value) {
204
+ const unreadable = !isRecord(value);
205
+ return Object.fromEntries(CONNECTION_ROWS.map(([key]) => [
206
+ key,
207
+ unreadable ? { status: 'unavailable', reason: '' } : normalizeConnectionRow(value[key]),
208
+ ]));
209
+ }
210
+
156
211
  function firstDate(values) {
157
212
  for (const value of values) {
158
213
  const date = parseDate(value);
@@ -239,19 +294,71 @@ function byCreatedAt(versions) {
239
294
  .map((item) => item.version);
240
295
  }
241
296
 
242
- function sortMeetings(meetings) {
297
+ function updatedTime(entry) {
298
+ let latest = latestVersionTime(entry);
299
+ for (const documentRecord of Array.isArray(entry?.minutes) ? entry.minutes : []) {
300
+ const date = parseDate(documentRecord?.updated_at);
301
+ if (date && (!latest || date > latest)) latest = date;
302
+ }
303
+ return latest ?? importTime(entry);
304
+ }
305
+
306
+ function sortMeetings(meetings, sort = 'scheduled-desc') {
307
+ const byUpdate = sort === 'updated-desc';
308
+ const direction = sort === 'scheduled-asc' ? 1 : -1;
243
309
  return meetings
244
- .map((entry, index) => ({ entry, index, timing: meetingTiming(entry) }))
310
+ .map((entry, index) => {
311
+ const timing = meetingTiming(entry);
312
+ const updated = byUpdate ? updatedTime(entry) : null;
313
+ return {
314
+ entry,
315
+ index,
316
+ bottom: byUpdate ? !updated : timing.kind === 'unregistered',
317
+ at: byUpdate ? updated?.getTime() ?? Number.NEGATIVE_INFINITY : timing.at,
318
+ };
319
+ })
245
320
  .sort((left, right) => {
246
- const leftBottom = left.timing.kind === 'unregistered';
247
- const rightBottom = right.timing.kind === 'unregistered';
248
- if (leftBottom !== rightBottom) return leftBottom ? 1 : -1;
249
- if (left.timing.at !== right.timing.at) return right.timing.at - left.timing.at;
321
+ if (left.bottom !== right.bottom) return left.bottom ? 1 : -1;
322
+ // Meetings without a date keep the newest import first in every order.
323
+ if (left.at !== right.at) return left.bottom ? right.at - left.at : (left.at - right.at) * direction;
250
324
  return left.index - right.index;
251
325
  })
252
326
  .map((item) => item.entry);
253
327
  }
254
328
 
329
+ function searchText(value) {
330
+ return String(value ?? '').normalize('NFKC').toLocaleLowerCase('ja');
331
+ }
332
+
333
+ function tokyoDateValue(date) {
334
+ const { year, month, day } = tokyoParts(date);
335
+ return `${year}-${pad(month)}-${pad(day)}`;
336
+ }
337
+
338
+ function participantsOf(entry) {
339
+ const ids = entry?.meeting?.participant_ids;
340
+ return Array.isArray(ids) ? ids.map((id) => text(id)).filter(Boolean) : [];
341
+ }
342
+
343
+ function matchesFilters(entry, filters) {
344
+ const terms = searchText(filters.query).split(/\s+/u).filter(Boolean);
345
+ if (terms.length) {
346
+ const documents = Array.isArray(entry?.minutes) ? entry.minutes : [];
347
+ const haystack = searchText([entry?.meeting?.title, ...documents.map((documentRecord) => documentRecord?.title)].join('\n'));
348
+ if (!terms.every((term) => haystack.includes(term))) return false;
349
+ }
350
+ if (filters.from || filters.to) {
351
+ const timing = meetingTiming(entry);
352
+ if (!timing.date) return false;
353
+ const day = tokyoDateValue(timing.date);
354
+ if (filters.from && day < filters.from) return false;
355
+ if (filters.to && day > filters.to) return false;
356
+ }
357
+ if (filters.participant && !participantsOf(entry).includes(filters.participant)) return false;
358
+ if (filters.status && listState(entry)?.code !== filters.status) return false;
359
+ return true;
360
+ }
361
+
255
362
  function idempotencyKey() {
256
363
  const random = typeof globalThis.crypto?.randomUUID === 'function'
257
364
  ? globalThis.crypto.randomUUID()
@@ -294,9 +401,24 @@ function sourceLabel(source) {
294
401
  return `${provider} / ${locator} / ${revision}`;
295
402
  }
296
403
 
297
- function renderMarkdown(doc, body) {
404
+ function sameTitle(left, right) {
405
+ const normalize = (value) => String(value ?? '').normalize('NFKC').replace(/\s+/gu, '');
406
+ const a = normalize(left);
407
+ return a !== '' && a === normalize(right);
408
+ }
409
+
410
+ // The meeting title is already the detail heading; a body that opens with the same
411
+ // heading would show it twice (story-meeting-minutes-edit-and-withdraw-ui-v1 AC-07).
412
+ function withoutRepeatedTitle(lines, title) {
413
+ const first = lines.findIndex((line) => line.trim() !== '');
414
+ if (first < 0) return lines;
415
+ const heading = /^#{1,6}\s+(.+)$/u.exec(lines[first]);
416
+ return heading && sameTitle(heading[1], title) ? lines.slice(first + 1) : lines;
417
+ }
418
+
419
+ function renderMarkdown(doc, body, { title } = {}) {
298
420
  const root = makeElement(doc, 'div', { className: 'bb-minutes-markdown' });
299
- const lines = String(body).replace(/\r\n/g, '\n').split('\n');
421
+ const lines = withoutRepeatedTitle(String(body).replace(/\r\n/g, '\n').split('\n'), title);
300
422
  let list = null;
301
423
  let listKind = null;
302
424
  const closeList = () => {
@@ -342,12 +464,12 @@ function renderMarkdown(doc, body) {
342
464
  return root;
343
465
  }
344
466
 
345
- function versionContent(doc, version) {
467
+ function versionContent(doc, version, { title } = {}) {
346
468
  if (!isRecord(version)) return makeElement(doc, 'p', { textContent: '版の形式を確認できません。', className: 'bb-minutes-warning' });
347
469
  if (isRecord(version.source_ref)) {
348
470
  return makeElement(doc, 'p', { className: 'bb-minutes-source', textContent: `本文は元の記録にあります。${sourceLabel(version.source_ref)}` });
349
471
  }
350
- if (typeof version.body === 'string') return renderMarkdown(doc, version.body);
472
+ if (typeof version.body === 'string') return renderMarkdown(doc, version.body, { title });
351
473
  return makeElement(doc, 'p', { className: 'bb-minutes-warning', textContent: '本文または外部参照を確認できません。' });
352
474
  }
353
475
 
@@ -513,8 +635,12 @@ export function createMeetingMinutesUI({
513
635
  basePath = '/api/meeting-minutes',
514
636
  versionActionExtensions = [],
515
637
  meetingLabel,
638
+ meetingConnections,
516
639
  canCreateMeeting,
640
+ createUnavailableReason,
517
641
  canEditMinutes,
642
+ hostFilters = [],
643
+ currentPrincipalId,
518
644
  now = () => new Date(),
519
645
  } = {}) {
520
646
  if (!root) throw new TypeError('root is required');
@@ -533,9 +659,20 @@ export function createMeetingMinutesUI({
533
659
  loading: false,
534
660
  listError: null,
535
661
  detailError: null,
662
+ filters: { ...EMPTY_FILTERS },
663
+ sort: SORTS[0].value,
664
+ // Which document is being edited or about to be withdrawn; both reset on every detail load.
665
+ editingMinutesId: null,
666
+ withdrawingMinutesId: null,
667
+ // { meetingId, rows } where rows is null while the host is answering.
668
+ connections: null,
536
669
  };
537
670
  let listRequestGeneration = 0;
538
671
  let detailRequestGeneration = 0;
672
+ let createReasonSequence = 0;
673
+ let connectionsSlot = null;
674
+ let railProjectSlot = null;
675
+ let railProjectMeeting = null;
539
676
 
540
677
  const screen = makeElement(doc, 'div', { className: 'bb-minutes-screen', attrs: { 'data-contract-version': MEETING_MINUTES_UI_CONTRACT_VERSION } });
541
678
  const heading = makeElement(doc, 'header', { className: 'bb-minutes-heading' });
@@ -545,6 +682,10 @@ export function createMeetingMinutesUI({
545
682
  makeElement(doc, 'p', { className: 'bb-minutes-lead', textContent: '新しい会議から読み、確認待ちを見つけて確認します。' }),
546
683
  );
547
684
  const status = makeElement(doc, 'p', { className: 'bb-minutes-status', attrs: { role: 'status', 'aria-live': 'polite' } });
685
+ const toolbar = makeElement(doc, 'div', {
686
+ className: 'bb-minutes-toolbar',
687
+ attrs: { role: 'search', 'aria-label': '議事録の絞り込みと並べ替え' },
688
+ });
548
689
  const columns = makeElement(doc, 'div', { className: 'bb-minutes-columns' });
549
690
  const listPane = makeElement(doc, 'section', {
550
691
  className: 'bb-minutes-list-pane',
@@ -552,9 +693,127 @@ export function createMeetingMinutesUI({
552
693
  });
553
694
  const detailPane = makeElement(doc, 'section', { className: 'bb-minutes-detail-pane', attrs: { 'aria-label': '議事録詳細' } });
554
695
  columns.append(listPane, detailPane);
555
- screen.append(heading, status, columns);
696
+ screen.append(heading, status, toolbar, columns);
556
697
  root.replaceChildren(screen);
557
698
 
699
+ function filterField(label, control, className = '') {
700
+ const field = makeElement(doc, 'div', { className: `bb-minutes-filter-field${className ? ` ${className}` : ''}` });
701
+ field.append(makeElement(doc, 'span', { className: 'bb-minutes-label', textContent: label }), control);
702
+ return field;
703
+ }
704
+
705
+ function filterCombobox(label, options, value, onChange) {
706
+ return workspaceCombobox(doc, { label, options, value, className: 'bb-minutes-filter-combobox', onChange });
707
+ }
708
+
709
+ const keyword = makeElement(doc, 'input', {
710
+ className: 'bb-minutes-input bb-minutes-filter-keyword',
711
+ type: 'search',
712
+ attrs: { 'aria-label': '会議名・議事録名で絞り込む', placeholder: '会議名・議事録名', autocomplete: 'off' },
713
+ });
714
+ keyword.addEventListener('input', () => { state.filters.query = keyword.value; renderList(); });
715
+ const fromInput = makeElement(doc, 'input', { className: 'bb-minutes-input', type: 'date', attrs: { 'aria-label': '期間の始め' } });
716
+ const toInput = makeElement(doc, 'input', { className: 'bb-minutes-input', type: 'date', attrs: { 'aria-label': '期間の終わり' } });
717
+ fromInput.addEventListener('change', () => { state.filters.from = fromInput.value; renderList(); });
718
+ toInput.addEventListener('change', () => { state.filters.to = toInput.value; renderList(); });
719
+ const period = makeElement(doc, 'span', { className: 'bb-minutes-filter-period' });
720
+ period.append(fromInput, makeElement(doc, 'span', { className: 'bb-minutes-filter-period-sep', textContent: '〜', attrs: { 'aria-hidden': 'true' } }), toInput);
721
+ const participantBox = filterCombobox('参加者', [{ value: '', label: 'すべての参加者' }], '', (value) => { state.filters.participant = value; renderList(); });
722
+ const statusBox = filterCombobox('確認の状態', STATE_FILTERS, '', (value) => { state.filters.status = value; renderList(); });
723
+ const sortBox = filterCombobox('並べ替え', SORTS, state.sort, (value) => { state.sort = value; renderList(); });
724
+ const hostSlot = makeElement(doc, 'div', { className: 'bb-minutes-filter-host' });
725
+ const participantField = filterField('参加者', participantBox.root);
726
+ const statusField = filterField('確認の状態', statusBox.root);
727
+ const reset = button(doc, '条件を外す', () => clearFilters(), { className: 'bb-minutes-button bb-minutes-filter-reset' });
728
+ toolbar.append(
729
+ filterField('キーワード', keyword, 'is-keyword'),
730
+ hostSlot,
731
+ filterField('期間', period, 'is-period'),
732
+ participantField,
733
+ statusField,
734
+ filterField('並べ替え', sortBox.root),
735
+ reset,
736
+ );
737
+ const hostBoxes = new Map();
738
+
739
+ function currentHostFilters() {
740
+ let filters = hostFilters;
741
+ if (typeof filters === 'function') {
742
+ try { filters = filters(); } catch { filters = []; }
743
+ }
744
+ return (Array.isArray(filters) ? filters : []).filter((filter) => isRecord(filter) && text(filter.id));
745
+ }
746
+
747
+ function clientFiltersActive() {
748
+ return Object.keys(EMPTY_FILTERS).some((key) => Boolean(state.filters[key]));
749
+ }
750
+
751
+ function filtersActive() {
752
+ return clientFiltersActive() || currentHostFilters().some((filter) => text(filter.value));
753
+ }
754
+
755
+ function clearFilters() {
756
+ state.filters = { ...EMPTY_FILTERS };
757
+ keyword.value = '';
758
+ fromInput.value = '';
759
+ toInput.value = '';
760
+ for (const filter of currentHostFilters()) {
761
+ if (text(filter.value) && typeof filter.onChange === 'function') filter.onChange('');
762
+ }
763
+ renderList();
764
+ }
765
+
766
+ function renderToolbar(meetings) {
767
+ const hosts = currentHostFilters();
768
+ const seen = new Set();
769
+ for (const filter of hosts) {
770
+ const id = text(filter.id);
771
+ seen.add(id);
772
+ const options = Array.isArray(filter.options) ? filter.options : [];
773
+ const value = text(filter.value);
774
+ let entry = hostBoxes.get(id);
775
+ if (!entry) {
776
+ const box = workspaceCombobox(doc, {
777
+ label: text(filter.label, id),
778
+ options,
779
+ value,
780
+ placeholder: text(filter.placeholder, '選択してください'),
781
+ className: 'bb-minutes-filter-combobox',
782
+ onChange: (next) => {
783
+ const current = currentHostFilters().find((candidate) => text(candidate.id) === id);
784
+ if (typeof current?.onChange === 'function') current.onChange(next);
785
+ },
786
+ });
787
+ entry = { box, field: filterField(text(filter.label, id), box.root) };
788
+ hostBoxes.set(id, entry);
789
+ }
790
+ entry.box.update({ options, value, disabled: filter.disabled === true });
791
+ }
792
+ for (const [id, entry] of hostBoxes) {
793
+ if (seen.has(id)) continue;
794
+ entry.box.destroy();
795
+ hostBoxes.delete(id);
796
+ }
797
+ hostSlot.replaceChildren(...hosts.map((filter) => hostBoxes.get(text(filter.id)).field));
798
+ hostSlot.hidden = hosts.length === 0;
799
+
800
+ // Offer only the conditions the loaded records can answer (P9).
801
+ const participants = [...new Set(meetings.flatMap(participantsOf))]
802
+ .sort((left, right) => left.localeCompare(right, 'ja', { numeric: true }));
803
+ if (state.filters.participant && !participants.includes(state.filters.participant)) state.filters.participant = '';
804
+ participantBox.update({
805
+ options: [{ value: '', label: 'すべての参加者' }, ...participants.map((participant) => ({ value: participant, label: participant }))],
806
+ value: state.filters.participant,
807
+ });
808
+ participantField.hidden = participants.length === 0;
809
+ const hasStates = meetings.some((entry) => listState(entry));
810
+ if (!hasStates) state.filters.status = '';
811
+ statusBox.update({ value: state.filters.status });
812
+ statusField.hidden = !hasStates;
813
+ sortBox.update({ value: state.sort });
814
+ reset.hidden = !filtersActive();
815
+ }
816
+
558
817
  function clock() {
559
818
  try {
560
819
  const value = typeof now === 'function' ? now() : new Date();
@@ -608,6 +867,34 @@ export function createMeetingMinutesUI({
608
867
  return canCreateMeeting !== false;
609
868
  }
610
869
 
870
+ /** One short reason from the host, shown beside a create button that cannot be pressed. */
871
+ function createReason() {
872
+ if (typeof createUnavailableReason === 'function') {
873
+ try {
874
+ return text(createUnavailableReason());
875
+ } catch {
876
+ return '';
877
+ }
878
+ }
879
+ return text(createUnavailableReason);
880
+ }
881
+
882
+ function appendCreateUnavailable(parent) {
883
+ const reason = createReason();
884
+ if (!reason) return;
885
+ const id = `bb-minutes-create-reason-${++createReasonSequence}`;
886
+ parent.append(
887
+ makeElement(doc, 'button', {
888
+ className: 'bb-minutes-button is-primary',
889
+ textContent: '会議を作成',
890
+ type: 'button',
891
+ disabled: true,
892
+ attrs: { type: 'button', 'aria-describedby': id },
893
+ }),
894
+ makeElement(doc, 'span', { className: 'bb-minutes-create-reason', textContent: reason, attrs: { id } }),
895
+ );
896
+ }
897
+
611
898
  function canEdit() {
612
899
  if (typeof canEditMinutes === 'function') {
613
900
  try {
@@ -619,6 +906,21 @@ export function createMeetingMinutesUI({
619
906
  return canEditMinutes !== false;
620
907
  }
621
908
 
909
+ function viewerId() {
910
+ try {
911
+ const value = typeof currentPrincipalId === 'function' ? currentPrincipalId() : currentPrincipalId;
912
+ return text(value);
913
+ } catch {
914
+ return '';
915
+ }
916
+ }
917
+
918
+ // The server decides; the screen only hides the button from people it already knows it will refuse.
919
+ function mayWithdraw(documentRecord) {
920
+ const viewer = viewerId();
921
+ return !viewer || viewer === text(documentRecord?.created_actor_id);
922
+ }
923
+
622
924
  function stateChip(versionStatus, { detailed = false } = {}) {
623
925
  const label = detailed && versionStatus.detail ? versionStatus.detail : versionStatus.label;
624
926
  return makeElement(doc, 'span', {
@@ -632,28 +934,122 @@ export function createMeetingMinutesUI({
632
934
  line.append(node);
633
935
  }
634
936
 
937
+ // One gray notice per detail, only when something cannot be done here.
635
938
  function appendReadOnlyNotice(parent, version) {
636
939
  const source = isRecord(version?.source_ref) ? version.source_ref : null;
940
+ const editable = canEdit();
941
+ if (editable && !source) return false;
637
942
  const url = httpUrl(source?.locator);
638
943
  const provider = text(source?.provider, '元の記録');
639
944
  const notice = makeElement(doc, 'div', { className: 'bb-minutes-edit-guard is-info' });
640
- notice.append(makeElement(doc, 'p', {
641
- textContent: canEdit()
642
- ? 'この画面では読むことと確認だけができます。'
643
- : 'この画面では読むことだけができます。確認はプロジェクトの編集権限がある人が行います。',
644
- }));
945
+ if (!editable) {
946
+ notice.append(makeElement(doc, 'p', {
947
+ textContent: 'この画面では読むことだけができます。確認はプロジェクトの編集権限がある人が行います。',
948
+ }));
949
+ }
645
950
  if (url) {
646
- const line = makeElement(doc, 'p', { textContent: '本文を直すときは、元の記録で編集してください。' });
951
+ const line = makeElement(doc, 'p', { textContent: '本文は元の記録にあります。本文を直すときは、元の記録で編集してください。' });
647
952
  line.append(makeElement(doc, 'a', {
648
953
  className: 'bb-minutes-source-link',
649
954
  textContent: `元の記録を開く(${provider})`,
650
955
  attrs: { href: url, target: '_blank', rel: 'noopener noreferrer' },
651
956
  }));
652
957
  notice.append(line);
653
- } else {
958
+ } else if (source) {
654
959
  notice.append(makeElement(doc, 'p', { textContent: '本文を直すときは、議事録を取り込んだ元で編集してください。' }));
960
+ } else {
961
+ notice.append(makeElement(doc, 'p', { textContent: '本文を直すのも、プロジェクトの編集権限がある人です。' }));
655
962
  }
656
963
  parent.append(notice);
964
+ return true;
965
+ }
966
+
967
+ function hasConnections() {
968
+ return typeof meetingConnections === 'function';
969
+ }
970
+
971
+ function currentConnections(meeting) {
972
+ const value = state.connections;
973
+ return value && value.meetingId === meeting?.meeting_id ? value.rows : null;
974
+ }
975
+
976
+ function connectionLink(item, className) {
977
+ if (!item.href) return makeElement(doc, 'span', { className, textContent: item.label });
978
+ const external = /^https?:/iu.test(item.href);
979
+ return makeElement(doc, 'a', {
980
+ className,
981
+ textContent: item.label,
982
+ attrs: {
983
+ href: item.href,
984
+ target: external ? '_blank' : undefined,
985
+ rel: external ? 'noopener noreferrer' : undefined,
986
+ },
987
+ });
988
+ }
989
+
990
+ function connectionValue(row) {
991
+ if (!row) return makeElement(doc, 'p', { className: 'bb-minutes-connection-note', textContent: '読み込んでいます' });
992
+ if (row.status === 'recorded') {
993
+ const list = makeElement(doc, 'ul', { className: 'bb-minutes-connection-items' });
994
+ for (const item of row.items) {
995
+ const entry = makeElement(doc, 'li');
996
+ entry.append(connectionLink(item, 'bb-minutes-connection-link'));
997
+ if (item.detail) entry.append(makeElement(doc, 'span', { className: 'bb-minutes-connection-detail', textContent: item.detail }));
998
+ list.append(entry);
999
+ }
1000
+ return list;
1001
+ }
1002
+ const label = row.status === 'none' ? 'なし' : row.status === 'unavailable' ? '読み込めません' : '記録なし';
1003
+ return makeElement(doc, 'p', {
1004
+ className: `bb-minutes-connection-note is-${row.status}`,
1005
+ textContent: row.reason ? `${label}(${row.reason})` : label,
1006
+ });
1007
+ }
1008
+
1009
+ function fillConnections(slot, rows) {
1010
+ slot.replaceChildren(makeElement(doc, 'h3', { className: 'bb-minutes-connections-title', textContent: 'この会議から' }));
1011
+ const list = makeElement(doc, 'div', { className: 'bb-minutes-connection-list' });
1012
+ for (const [key, label] of CONNECTION_ROWS) {
1013
+ const row = makeElement(doc, 'div', { className: 'bb-minutes-connection-row', attrs: { 'data-connection': key } });
1014
+ row.append(
1015
+ makeElement(doc, 'span', { className: 'bb-minutes-connection-label', textContent: label }),
1016
+ connectionValue(rows ? rows[key] : null),
1017
+ );
1018
+ list.append(row);
1019
+ }
1020
+ slot.append(list);
1021
+ }
1022
+
1023
+ function fillRailProject(slot, meeting) {
1024
+ const project = currentConnections(meeting)?.project;
1025
+ const item = project?.status === 'recorded' ? project.items.find((candidate) => candidate.href) : null;
1026
+ if (item) {
1027
+ slot.textContent = '';
1028
+ slot.replaceChildren(connectionLink(item, 'bb-minutes-source-link'));
1029
+ return;
1030
+ }
1031
+ const label = meetingLabelText(meetingLabel, meeting);
1032
+ slot.replaceChildren();
1033
+ slot.textContent = label || 'プロジェクトは記録されていません';
1034
+ }
1035
+
1036
+ async function loadConnections(detail, isCurrentRequest) {
1037
+ const meetingId = detail.meeting.meeting_id;
1038
+ state.connections = { meetingId, rows: null };
1039
+ let rows;
1040
+ try {
1041
+ rows = normalizeConnections(await meetingConnections({
1042
+ meeting: detail.meeting,
1043
+ minutes: detail.minutes,
1044
+ versions: detail.versions,
1045
+ }));
1046
+ } catch {
1047
+ rows = normalizeConnections(null);
1048
+ }
1049
+ if (!isCurrentRequest() || state.connections?.meetingId !== meetingId) return;
1050
+ state.connections = { meetingId, rows };
1051
+ if (connectionsSlot) fillConnections(connectionsSlot, rows);
1052
+ if (railProjectSlot && railProjectMeeting?.meeting_id === meetingId) fillRailProject(railProjectSlot, railProjectMeeting);
657
1053
  }
658
1054
 
659
1055
  function appendLoadError(parent, message, retry) {
@@ -666,7 +1062,7 @@ export function createMeetingMinutesUI({
666
1062
  parent.append(notice);
667
1063
  }
668
1064
 
669
- function renderMeetingRow(entry) {
1065
+ function renderMeetingRow(entry, { flat = false } = {}) {
670
1066
  const meeting = entry.meeting;
671
1067
  const timing = meetingTiming(entry);
672
1068
  const item = makeElement(doc, 'button', {
@@ -680,10 +1076,19 @@ export function createMeetingMinutesUI({
680
1076
  }));
681
1077
  const line = makeElement(doc, 'span', { className: 'bb-minutes-item-line' });
682
1078
  if (timing.kind === 'scheduled') {
683
- appendLinePart(line, makeElement(doc, 'span', { textContent: formatClock(timing.date) }));
1079
+ // Without date headings the row carries its own date.
1080
+ const when = flat ? `${formatMonthDay(timing.date)} ${formatClock(timing.date)}` : formatClock(timing.date);
1081
+ appendLinePart(line, makeElement(doc, 'span', { textContent: when }));
684
1082
  } else if (timing.kind === 'fallback' || (timing.kind === 'unregistered' && timing.date)) {
685
1083
  appendLinePart(line, makeElement(doc, 'span', { textContent: unscheduledLabel(timing) }));
686
1084
  }
1085
+ if (flat) {
1086
+ const updated = updatedTime(entry);
1087
+ appendLinePart(line, makeElement(doc, 'span', {
1088
+ className: 'bb-minutes-item-updated',
1089
+ textContent: updated ? `更新 ${formatMonthDay(updated)} ${formatClock(updated)}` : '更新日時は未記録',
1090
+ }));
1091
+ }
687
1092
  const label = meetingLabelText(meetingLabel, meeting);
688
1093
  if (label) appendLinePart(line, makeElement(doc, 'span', { className: 'bb-minutes-meeting-label', textContent: label }));
689
1094
  const rowState = listState(entry);
@@ -701,17 +1106,19 @@ export function createMeetingMinutesUI({
701
1106
  clear(listPane);
702
1107
  const head = makeElement(doc, 'div', { className: 'bb-minutes-pane-head' });
703
1108
  const meetings = state.meetings.filter((entry) => isRecord(entry?.meeting) && typeof entry.meeting.meeting_id === 'string');
1109
+ renderToolbar(meetings);
1110
+ const visible = sortMeetings(meetings.filter((entry) => matchesFilters(entry, state.filters)), state.sort);
704
1111
  if (canCreate()) {
705
1112
  head.append(button(doc, '会議を作成', () => renderCreateForm(), { className: 'bb-minutes-button is-primary' }));
1113
+ } else {
1114
+ appendCreateUnavailable(head);
706
1115
  }
707
- head.append(makeElement(doc, 'p', { className: 'bb-minutes-sort-note', textContent: `新しい順・${meetings.length}件` }));
1116
+ const narrowed = clientFiltersActive();
1117
+ head.append(makeElement(doc, 'p', {
1118
+ className: 'bb-minutes-sort-note',
1119
+ textContent: narrowed ? `${meetings.length}件中${visible.length}件` : `${meetings.length}件`,
1120
+ }));
708
1121
  listPane.append(head);
709
- if (!canCreate()) {
710
- listPane.append(makeElement(doc, 'p', {
711
- className: 'bb-minutes-create-guard',
712
- textContent: '会議を作成する権限がありません。作成はプロジェクトの編集権限がある人が行います。',
713
- }));
714
- }
715
1122
  if (state.listError) {
716
1123
  appendLoadError(listPane, `会議一覧を読み込めませんでした。理由: ${state.listError}`, () => loadMeetings({ restoreFocus: true }));
717
1124
  }
@@ -719,14 +1126,27 @@ export function createMeetingMinutesUI({
719
1126
  if (state.listError) return;
720
1127
  listPane.append(makeElement(doc, 'p', {
721
1128
  className: 'bb-minutes-empty',
722
- textContent: 'まだ議事録はありません。会議を取り込むと、ここに新しい順で並びます。',
1129
+ textContent: filtersActive()
1130
+ ? '条件に合う会議はありません。'
1131
+ : 'まだ議事録はありません。会議を取り込むと、ここに新しい順で並びます。',
723
1132
  }));
1133
+ if (filtersActive()) listPane.append(button(doc, '条件を外す', () => clearFilters()));
1134
+ return;
1135
+ }
1136
+ if (visible.length === 0) {
1137
+ listPane.append(makeElement(doc, 'p', { className: 'bb-minutes-empty', textContent: '条件に合う会議はありません。' }));
1138
+ listPane.append(button(doc, '条件を外す', () => clearFilters()));
724
1139
  return;
725
1140
  }
726
1141
  const list = makeElement(doc, 'div', { className: 'bb-minutes-meeting-list' });
1142
+ if (state.sort === 'updated-desc') {
1143
+ for (const entry of visible) list.append(renderMeetingRow(entry, { flat: true }));
1144
+ listPane.append(list);
1145
+ return;
1146
+ }
727
1147
  const today = clock();
728
1148
  let currentKey = null;
729
- for (const entry of meetings) {
1149
+ for (const entry of visible) {
730
1150
  const timing = meetingTiming(entry);
731
1151
  if (timing.kind === 'unregistered') continue;
732
1152
  const key = dateKey(timing.date);
@@ -739,7 +1159,7 @@ export function createMeetingMinutesUI({
739
1159
  }
740
1160
  list.append(renderMeetingRow(entry));
741
1161
  }
742
- const unregistered = meetings.filter((entry) => meetingTiming(entry).kind === 'unregistered');
1162
+ const unregistered = visible.filter((entry) => meetingTiming(entry).kind === 'unregistered');
743
1163
  if (unregistered.length) {
744
1164
  list.append(makeElement(doc, 'h3', {
745
1165
  className: 'bb-minutes-date-heading',
@@ -753,6 +1173,8 @@ export function createMeetingMinutesUI({
753
1173
  function renderRail(version, documentRecord, meeting) {
754
1174
  if (!rail) return;
755
1175
  clear(rail);
1176
+ railProjectSlot = null;
1177
+ railProjectMeeting = null;
756
1178
  const block = makeElement(doc, 'section', { className: 'bb-minutes-rail-block' });
757
1179
  block.append(makeElement(doc, 'h2', { textContent: '関連' }));
758
1180
  if (!meeting && !version) {
@@ -763,8 +1185,10 @@ export function createMeetingMinutesUI({
763
1185
  block.append(makeElement(doc, 'h3', { textContent: '予定' }));
764
1186
  block.append(makeElement(doc, 'p', { className: 'bb-minutes-rail-note', textContent: '予定とのつながりは未対応です。' }));
765
1187
  block.append(makeElement(doc, 'h3', { textContent: 'プロジェクト' }));
766
- const label = meetingLabelText(meetingLabel, meeting);
767
- block.append(makeElement(doc, 'p', { textContent: label || 'プロジェクトは記録されていません' }));
1188
+ railProjectSlot = makeElement(doc, 'p', { className: 'bb-minutes-rail-project' });
1189
+ railProjectMeeting = meeting;
1190
+ fillRailProject(railProjectSlot, meeting);
1191
+ block.append(railProjectSlot);
768
1192
  appendIdentifiers(doc, block, {
769
1193
  meetingId: meeting?.meeting_id,
770
1194
  minutesId: documentRecord?.minutes_id,
@@ -773,6 +1197,11 @@ export function createMeetingMinutesUI({
773
1197
  rail.append(block);
774
1198
  }
775
1199
 
1200
+ function rerender(detail, documentRecord, version) {
1201
+ clear(detailPane);
1202
+ renderDetail(detail, documentRecord, version);
1203
+ }
1204
+
776
1205
  function renderVersion(detail, documentRecord, selectedVersion, noticeState) {
777
1206
  const versions = detailVersions(detail, documentRecord.minutes_id);
778
1207
  const latest = versions.find((version) => version.version_id === documentRecord.current_version_id) ?? versions[versions.length - 1] ?? null;
@@ -782,105 +1211,143 @@ export function createMeetingMinutesUI({
782
1211
  const selectedState = versionState(selected, versions, latest);
783
1212
  const latestState = versionState(latest, versions, latest);
784
1213
  const viewingOld = Boolean(selected && latest && selected.version_id !== latest.version_id);
1214
+ const withdrawal = isRecord(documentRecord.withdrawal) ? documentRecord.withdrawal : null;
1215
+ const native = isRecord(selected) && typeof selected.body === 'string' && !isRecord(selected.source_ref);
1216
+ const editing = state.editingMinutesId === documentRecord.minutes_id && native && !viewingOld && !withdrawal && canEdit();
785
1217
  const section = makeElement(doc, 'section', { className: 'bb-minutes-document' });
1218
+ if (withdrawal) {
1219
+ const when = formatStamp(withdrawal.withdrawn_at);
1220
+ const who = text(withdrawal.actor_id);
1221
+ section.append(makeElement(doc, 'p', {
1222
+ className: 'bb-minutes-withdrawn',
1223
+ textContent: `取り下げ済み(${[who, when].filter(Boolean).join('・') || '取り下げた人と日時は未記録'})。プロジェクトの議事録の一覧には出ません。`,
1224
+ }));
1225
+ }
786
1226
  if (viewingOld) {
787
1227
  const banner = makeElement(doc, 'div', { className: 'bb-minutes-old-version' });
788
1228
  const latestName = latestState.code === 'confirmed' ? '確認済み' : latestState.label;
789
1229
  banner.append(makeElement(doc, 'p', {
790
1230
  textContent: `古い版を表示しています。最新は${versionLabel(versions, latest)}(${latestName})です。`,
791
1231
  }));
792
- banner.append(button(doc, '最新の版を見る', () => {
793
- clear(detailPane);
794
- renderDetail(detail, documentRecord, latest);
795
- }));
1232
+ banner.append(button(doc, '最新の版を見る', () => rerender(detail, documentRecord, latest)));
796
1233
  section.append(banner);
797
1234
  }
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);
1235
+ // State and actions come first; which version this is goes below the body.
1236
+ const actions = makeElement(doc, 'div', { className: 'bb-minutes-version-actions' });
1237
+ actions.append(stateChip(selectedState, { detailed: true }));
1238
+ if (!withdrawal && !editing) {
1239
+ if (selected && canEdit() && !isRecord(selected.confirmation) && selectedState.code !== 'unknown' && selectedState.code !== 'creating') {
1240
+ const confirm = button(doc, 'この版を確認済みにする', async () => {
1241
+ if (!canEdit()) {
1242
+ rerender(detail, documentRecord, selected);
1243
+ return;
1244
+ }
1245
+ confirm.disabled = true;
1246
+ try {
1247
+ await mutate(`/${encodeURIComponent(detail.meeting.meeting_id)}/minutes/${encodeURIComponent(documentRecord.minutes_id)}/confirm`, {
1248
+ version_id: selected.version_id,
1249
+ expected_revision: documentRecord.revision,
1250
+ });
1251
+ await loadDetail(detail.meeting.meeting_id);
1252
+ if (!state.detailError) setStatus(`${selectedLabel}を確認済みにしました。`, 'success');
1253
+ } catch (error) {
1254
+ confirm.disabled = false;
1255
+ setStatus(error instanceof Error ? error.message : '確認を保存できませんでした。', 'error');
1256
+ }
1257
+ }, { className: 'bb-minutes-button is-primary' });
1258
+ actions.append(confirm);
1259
+ }
1260
+ if (native && !viewingOld && canEdit()) {
1261
+ actions.append(button(doc, '編集', () => {
1262
+ state.editingMinutesId = documentRecord.minutes_id;
1263
+ state.withdrawingMinutesId = null;
1264
+ rerender(detail, documentRecord, selected);
1265
+ }));
1266
+ }
1267
+ if (canEdit() && mayWithdraw(documentRecord)) {
1268
+ actions.append(button(doc, '取り下げる', () => {
1269
+ state.withdrawingMinutesId = documentRecord.minutes_id;
1270
+ state.editingMinutesId = null;
1271
+ rerender(detail, documentRecord, selected);
1272
+ }, { className: 'bb-minutes-button is-quiet' }));
1273
+ }
815
1274
  }
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);
1275
+ section.append(actions);
1276
+ if (!withdrawal && canEdit() && !mayWithdraw(documentRecord)) {
1277
+ section.append(makeElement(doc, 'p', { className: 'bb-minutes-withdraw-guard', textContent: '取り下げられるのは、この議事録を作った人だけです。' }));
1278
+ }
1279
+ if (!withdrawal && state.withdrawingMinutesId === documentRecord.minutes_id && canEdit() && mayWithdraw(documentRecord)) {
1280
+ section.append(renderWithdrawConfirm(detail, documentRecord, selected));
842
1281
  }
843
- section.append(history);
1282
+ if (!noticeState.placed && appendReadOnlyNotice(section, selected)) noticeState.placed = true;
844
1283
  const content = makeElement(doc, 'div', { className: 'bb-minutes-version-content' });
845
1284
  const bodyRoot = makeElement(doc, 'div', {
846
1285
  className: 'bb-minutes-external-body-slot',
847
1286
  attrs: { 'data-minutes-external-body': 'true' },
848
1287
  });
849
- content.append(versionContent(doc, selected), bodyRoot);
1288
+ if (editing) {
1289
+ content.append(renderEditForm(detail, documentRecord, selected, versions));
1290
+ } else {
1291
+ content.append(versionContent(doc, selected, { title: detail.meeting?.title }), bodyRoot);
1292
+ }
850
1293
  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);
1294
+ const meta = makeElement(doc, 'div', { className: 'bb-minutes-version-meta' });
1295
+ const latestMark = selected && latest && selected.version_id === latest.version_id ? '(最新)' : '';
1296
+ meta.append(makeElement(doc, 'span', {
1297
+ className: 'bb-minutes-item-meta',
1298
+ textContent: [`${selectedLabel}${latestMark}`, historyStamp(selected, clock()), creatorFact(selected)].join('・'),
1299
+ }));
1300
+ section.append(meta);
1301
+ if (versions.length >= 2) {
1302
+ const history = makeElement(doc, 'div', { className: 'bb-minutes-version-history is-collapsed', attrs: { 'aria-label': '版の履歴' } });
1303
+ const historyButton = button(doc, `版の履歴(${versions.length}件)`, () => {
1304
+ const collapsed = history.className.includes('is-collapsed');
1305
+ history.className = collapsed ? 'bb-minutes-version-history' : 'bb-minutes-version-history is-collapsed';
1306
+ historyButton.textContent = collapsed ? `版の履歴を閉じる(${versions.length}件)` : `版の履歴(${versions.length}件)`;
1307
+ });
1308
+ meta.append(historyButton);
1309
+ for (const version of [...versions].reverse()) {
1310
+ const versionButton = makeElement(doc, 'button', {
1311
+ className: `bb-minutes-version${version.version_id === selected?.version_id ? ' is-selected' : ''}`,
1312
+ type: 'button',
1313
+ attrs: { type: 'button' },
1314
+ });
1315
+ const itemState = versionState(version, versions, latest);
1316
+ const isLatest = Boolean(latest) && version.version_id === latest.version_id;
1317
+ const line1 = makeElement(doc, 'span', { className: 'bb-minutes-version-line' });
1318
+ line1.append(
1319
+ makeElement(doc, 'strong', { textContent: `${versionLabel(versions, version)}${isLatest ? '(最新)' : ''}` }),
1320
+ stateChip(itemState),
1321
+ makeElement(doc, 'span', { className: 'bb-minutes-version-stamp', textContent: historyStamp(version, clock()) }),
1322
+ );
1323
+ versionButton.append(
1324
+ line1,
1325
+ makeElement(doc, 'span', {
1326
+ className: 'bb-minutes-version-line is-secondary',
1327
+ textContent: historySecondLine(version, versions, latest),
1328
+ }),
1329
+ );
1330
+ versionButton.addEventListener('click', () => {
1331
+ state.editingMinutesId = null;
1332
+ rerender(detail, documentRecord, version);
1333
+ });
1334
+ history.append(versionButton);
1335
+ }
1336
+ section.append(history);
1337
+ }
1338
+ if (!editing) {
1339
+ appendVersionActionExtensions(doc, section, {
1340
+ extensions: versionActionExtensions,
1341
+ detail,
1342
+ documentRecord,
1343
+ version: selected,
1344
+ request: call,
1345
+ mutate,
1346
+ refresh: () => loadDetail(detail.meeting.meeting_id),
1347
+ setStatus,
1348
+ bodyRoot,
1349
+ });
872
1350
  }
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
1351
  appendIdentifiers(doc, section, {
885
1352
  meetingId: detail.meeting?.meeting_id,
886
1353
  minutesId: documentRecord.minutes_id,
@@ -890,8 +1357,97 @@ export function createMeetingMinutesUI({
890
1357
  return section;
891
1358
  }
892
1359
 
1360
+ // Editing writes a new version; the earlier versions stay (story-meeting-minutes-edit-and-withdraw-ui-v1 AC-01).
1361
+ function renderEditForm(detail, documentRecord, selected, versions) {
1362
+ const form = makeElement(doc, 'form', { className: 'bb-minutes-edit-form' });
1363
+ const field = labelInput(doc, '本文', { type: 'textarea', name: 'body', value: selected.body });
1364
+ field.input.value = selected.body;
1365
+ field.input.rows = 16;
1366
+ form.append(
1367
+ field.wrapper,
1368
+ makeElement(doc, 'p', { className: 'bb-minutes-edit-note', textContent: `保存すると第${versions.length + 1}版になります。前の版は残ります。` }),
1369
+ makeElement(doc, 'p', { className: 'bb-minutes-edit-note', textContent: '公開したときにGitへ書き出した分とSlackの知らせは、前の版のまま変わりません。' }),
1370
+ );
1371
+ const save = makeElement(doc, 'button', { className: 'bb-minutes-button is-primary', textContent: '新しい版として保存', type: 'submit' });
1372
+ const cancel = button(doc, 'やめる', () => {
1373
+ state.editingMinutesId = null;
1374
+ rerender(detail, documentRecord, selected);
1375
+ });
1376
+ const row = makeElement(doc, 'div', { className: 'bb-minutes-actions' });
1377
+ row.append(save, cancel);
1378
+ form.append(row);
1379
+ form.addEventListener('submit', async (event) => {
1380
+ event.preventDefault();
1381
+ if (!canEdit()) {
1382
+ state.editingMinutesId = null;
1383
+ rerender(detail, documentRecord, selected);
1384
+ return;
1385
+ }
1386
+ save.disabled = true;
1387
+ try {
1388
+ await mutate(`/${encodeURIComponent(detail.meeting.meeting_id)}/minutes/${encodeURIComponent(documentRecord.minutes_id)}`, {
1389
+ body: field.input.value,
1390
+ expected_revision: documentRecord.revision,
1391
+ });
1392
+ state.editingMinutesId = null;
1393
+ await loadDetail(detail.meeting.meeting_id);
1394
+ if (!state.detailError) setStatus(`第${versions.length + 1}版として保存しました。確認待ちです。`, 'success');
1395
+ } catch (error) {
1396
+ save.disabled = false;
1397
+ setStatus(error instanceof Error ? error.message : '新しい版を保存できませんでした。', 'error');
1398
+ }
1399
+ });
1400
+ return form;
1401
+ }
1402
+
1403
+ // Withdrawal takes the document back without deleting it (台帳 D9, story-meeting-minutes-withdraw-v1).
1404
+ function renderWithdrawConfirm(detail, documentRecord, selected) {
1405
+ const panel = makeElement(doc, 'div', { className: 'bb-minutes-withdraw-confirm', attrs: { role: 'group', 'aria-label': '取り下げの確認' } });
1406
+ panel.append(makeElement(doc, 'p', { className: 'bb-minutes-withdraw-title', textContent: 'この議事録を取り下げますか?' }));
1407
+ const facts = makeElement(doc, 'ul', { className: 'bb-minutes-md-list' });
1408
+ for (const line of [
1409
+ 'プロジェクトの議事録の一覧から外れます。',
1410
+ '版と本文は消えず、取り下げた人と日時が残ります。',
1411
+ 'Gitへ書き出した分と、Slackで送った知らせは取り消せません。',
1412
+ '取り下げは元に戻せません。もう一度公開すると、新しい議事録として作られます。',
1413
+ ]) facts.append(makeElement(doc, 'li', { textContent: line }));
1414
+ panel.append(facts);
1415
+ const row = makeElement(doc, 'div', { className: 'bb-minutes-actions' });
1416
+ const confirm = button(doc, '取り下げる', async () => {
1417
+ if (!canEdit() || !mayWithdraw(documentRecord)) {
1418
+ state.withdrawingMinutesId = null;
1419
+ rerender(detail, documentRecord, selected);
1420
+ return;
1421
+ }
1422
+ confirm.disabled = true;
1423
+ try {
1424
+ await mutate(`/${encodeURIComponent(detail.meeting.meeting_id)}/minutes/${encodeURIComponent(documentRecord.minutes_id)}/withdraw`, {
1425
+ expected_revision: documentRecord.revision,
1426
+ });
1427
+ state.withdrawingMinutesId = null;
1428
+ await loadMeetings();
1429
+ await loadDetail(detail.meeting.meeting_id);
1430
+ if (!state.detailError) setStatus('議事録を取り下げました。', 'success');
1431
+ } catch (error) {
1432
+ confirm.disabled = false;
1433
+ const denied = error?.status === 403;
1434
+ setStatus(denied
1435
+ ? '取り下げられませんでした。取り下げられるのは、この議事録を作った人だけです。'
1436
+ : error instanceof Error ? error.message : '取り下げられませんでした。', 'error');
1437
+ }
1438
+ }, { className: 'bb-minutes-button is-danger' });
1439
+ const cancel = button(doc, 'やめる', () => {
1440
+ state.withdrawingMinutesId = null;
1441
+ rerender(detail, documentRecord, selected);
1442
+ });
1443
+ row.append(confirm, cancel);
1444
+ panel.append(row);
1445
+ return panel;
1446
+ }
1447
+
893
1448
  function renderDetail(detail, documentRecord = null, selectedVersion = null) {
894
1449
  clear(detailPane);
1450
+ connectionsSlot = null;
895
1451
  if (state.detailError) {
896
1452
  const retry = state.meetingId === null ? loadMeetings : () => loadDetail(state.meetingId);
897
1453
  appendLoadError(detailPane, state.detailError.startsWith('会議詳細を読み込めませんでした。')
@@ -924,6 +1480,14 @@ export function createMeetingMinutesUI({
924
1480
  textContent: participants ? `${when} / ${participants}` : when,
925
1481
  }));
926
1482
  detailPane.append(header);
1483
+ if (hasConnections()) {
1484
+ connectionsSlot = makeElement(doc, 'section', {
1485
+ className: 'bb-minutes-connections',
1486
+ attrs: { 'aria-label': 'この会議から' },
1487
+ });
1488
+ fillConnections(connectionsSlot, currentConnections(meeting));
1489
+ detailPane.append(connectionsSlot);
1490
+ }
927
1491
  const documents = Array.isArray(detail.minutes) ? detail.minutes : [];
928
1492
  const noticeState = { placed: false };
929
1493
  if (documents.length === 0) {
@@ -941,14 +1505,17 @@ export function createMeetingMinutesUI({
941
1505
  }
942
1506
  }
943
1507
 
1508
+ function renderCreateUnavailable() {
1509
+ clear(detailPane);
1510
+ detailPane.append(makeElement(doc, 'p', { className: 'bb-minutes-empty', textContent: '議事録を選ぶと、ここに本文が出ます。' }));
1511
+ const reason = createReason();
1512
+ if (reason) detailPane.append(makeElement(doc, 'p', { className: 'bb-minutes-create-reason', textContent: reason }));
1513
+ }
1514
+
944
1515
  function renderCreateForm() {
945
1516
  clear(detailPane);
946
1517
  if (!canCreate()) {
947
- detailPane.append(makeElement(doc, 'p', { className: 'bb-minutes-empty', textContent: '議事録を選ぶと、ここに本文が出ます。' }));
948
- detailPane.append(makeElement(doc, 'p', {
949
- className: 'bb-minutes-create-guard',
950
- textContent: '会議を作成する権限がありません。作成はプロジェクトの編集権限がある人が行います。',
951
- }));
1518
+ renderCreateUnavailable();
952
1519
  return;
953
1520
  }
954
1521
  const form = makeElement(doc, 'form', { className: 'bb-minutes-create-form' });
@@ -966,12 +1533,7 @@ export function createMeetingMinutesUI({
966
1533
  if (!canCreate()) {
967
1534
  save.disabled = true;
968
1535
  renderList();
969
- clear(detailPane);
970
- detailPane.append(makeElement(doc, 'p', { className: 'bb-minutes-empty', textContent: '議事録を選ぶと、ここに本文が出ます。' }));
971
- detailPane.append(makeElement(doc, 'p', {
972
- className: 'bb-minutes-create-guard',
973
- textContent: '会議を作成する権限がありません。作成はプロジェクトの編集権限がある人が行います。',
974
- }));
1536
+ renderCreateUnavailable();
975
1537
  return;
976
1538
  }
977
1539
  save.disabled = true;
@@ -1058,6 +1620,8 @@ export function createMeetingMinutesUI({
1058
1620
  state.meetingId = meetingId;
1059
1621
  state.minutesId = null;
1060
1622
  state.detailError = null;
1623
+ state.editingMinutesId = null;
1624
+ state.withdrawingMinutesId = null;
1061
1625
  renderList();
1062
1626
  setStatus('会議詳細を読み込んでいます。');
1063
1627
  try {
@@ -1066,8 +1630,10 @@ export function createMeetingMinutesUI({
1066
1630
  if (!isRecord(detail) || !isRecord(detail.meeting) || !Array.isArray(detail.minutes) || !Array.isArray(detail.versions)) throw new Error('会議詳細の形式を確認できません。');
1067
1631
  state.detail = detail;
1068
1632
  state.detailError = null;
1633
+ if (hasConnections()) state.connections = { meetingId, rows: null };
1069
1634
  renderDetail(detail);
1070
1635
  setStatus('会議詳細を読み込みました。');
1636
+ if (hasConnections()) void loadConnections(detail, isCurrentRequest);
1071
1637
  } catch (error) {
1072
1638
  if (!isCurrentRequest()) return state.detail;
1073
1639
  const preserved = preserveLoadedStateOnFailure(error) && state.detail?.meeting?.meeting_id === meetingId
@@ -1081,6 +1647,7 @@ export function createMeetingMinutesUI({
1081
1647
  return state.detail;
1082
1648
  }
1083
1649
 
1650
+ renderToolbar([]);
1084
1651
  renderDetail(null);
1085
1652
  void loadMeetings();
1086
1653
  return {