@unson/brainbase-mcp 0.41.9 → 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.
@@ -31,6 +31,8 @@ export interface CanonicalTaskRecord extends JsonRecord {
31
31
  assignee_display_name?: string | null;
32
32
  due_at?: string | null;
33
33
  waiting_on?: string | null;
34
+ /** The Graph person a waiting task waits on; null when it waits on a condition or nobody in particular. */
35
+ waiting_on_person_id?: string | null;
34
36
  review_at?: string | null;
35
37
  completed_at?: string | null;
36
38
  source_refs?: unknown[];
@@ -50,6 +52,7 @@ export interface CanonicalTaskCreateInput extends JsonRecord {
50
52
  assignee_person_id?: string | null;
51
53
  due_at?: string | null;
52
54
  waiting_on?: string | null;
55
+ waiting_on_person_id?: string | null;
53
56
  review_at?: string | null;
54
57
  source_refs?: unknown[];
55
58
  project_codes?: unknown;
@@ -63,6 +66,7 @@ export interface CanonicalTaskUpdateInput extends JsonRecord {
63
66
  assignee_person_id?: string | null;
64
67
  due_at?: string | null;
65
68
  waiting_on?: string | null;
69
+ waiting_on_person_id?: string | null;
66
70
  review_at?: string | null;
67
71
  source_refs?: unknown[];
68
72
  source_refs_append?: unknown[];
@@ -231,12 +235,14 @@ export declare class CanonicalTaskService {
231
235
  }, context: CanonicalTaskContext): Promise<CanonicalTaskRecord>;
232
236
  transitionTask(taskId: string, status: unknown, expectedVersion: number, context: CanonicalTaskContext, input?: {
233
237
  waitingOn?: unknown;
238
+ waitingOnPersonId?: unknown;
234
239
  reviewAt?: unknown;
235
240
  }): Promise<CanonicalTaskRecord>;
236
241
  transitionTask(taskId: string, input: {
237
242
  to_status?: unknown;
238
243
  expected_version?: number;
239
244
  waiting_on?: unknown;
245
+ waiting_on_person_id?: unknown;
240
246
  review_at?: unknown;
241
247
  }, context: CanonicalTaskContext): Promise<CanonicalTaskRecord>;
242
248
  deleteTask(taskId: string, expectedVersion: number, context: CanonicalTaskContext): Promise<{
@@ -34,6 +34,7 @@ const CREATE_FIELDS = new Set([
34
34
  'assignee_person_id',
35
35
  'due_at',
36
36
  'waiting_on',
37
+ 'waiting_on_person_id',
37
38
  'review_at',
38
39
  'source_refs',
39
40
  'project_codes',
@@ -48,6 +49,7 @@ const MUTABLE_FIELDS = new Set([
48
49
  'assignee_person_id',
49
50
  'due_at',
50
51
  'waiting_on',
52
+ 'waiting_on_person_id',
51
53
  'review_at',
52
54
  'source_refs',
53
55
  'source_refs_append',
@@ -78,6 +80,10 @@ function normalizeString(value, field, options = {}) {
78
80
  }
79
81
  return normalized;
80
82
  }
83
+ /** A Graph person ID, as for the assignee; empty or null means no person. */
84
+ function normalizeWaitingOnPersonId(value) {
85
+ return normalizeString(value, 'waiting_on_person_id', { max: 200 });
86
+ }
81
87
  /** A purpose label: a single line of at most 30 characters; empty or null clears it. */
82
88
  const MAX_PURPOSE_LABEL_LENGTH = 30;
83
89
  function normalizePurposeLabel(value) {
@@ -511,7 +517,7 @@ export class CanonicalTaskService {
511
517
  const expectedVersion = legacyInput ? legacyInput.expected_version : expectedVersionOrContext;
512
518
  const context = legacyInput ? expectedVersionOrContext : maybeContext;
513
519
  const input = legacyInput
514
- ? { waitingOn: legacyInput.waiting_on, reviewAt: legacyInput.review_at }
520
+ ? { waitingOn: legacyInput.waiting_on, waitingOnPersonId: legacyInput.waiting_on_person_id, reviewAt: legacyInput.review_at }
515
521
  : maybeInput;
516
522
  const normalizedContext = await this.normalizeContext(context);
517
523
  const expected = this.requireExpectedVersion(expectedVersion);
@@ -523,10 +529,13 @@ export class CanonicalTaskService {
523
529
  failValidation({ waiting_on: ['required_for_waiting'] });
524
530
  }
525
531
  patch.waiting_on = normalizeString(input.waitingOn, 'waiting_on', { required: true, max: 500 });
532
+ // The person is optional and always replaced, so waiting on a condition clears an earlier person.
533
+ patch.waiting_on_person_id = normalizeWaitingOnPersonId(input.waitingOnPersonId);
526
534
  patch.review_at = normalizeIsoDate(input.reviewAt, 'review_at');
527
535
  }
528
536
  else {
529
537
  patch.waiting_on = input.waitingOn === undefined ? null : normalizeString(input.waitingOn, 'waiting_on', { max: 500 });
538
+ patch.waiting_on_person_id = input.waitingOnPersonId === undefined ? null : normalizeWaitingOnPersonId(input.waitingOnPersonId);
530
539
  patch.review_at = input.reviewAt === undefined ? null : normalizeIsoDate(input.reviewAt, 'review_at');
531
540
  }
532
541
  return this.versionedMutation({
@@ -801,6 +810,7 @@ export class CanonicalTaskService {
801
810
  transition: {
802
811
  to_status: input.transitionStatus,
803
812
  ...(input.patch.waiting_on !== undefined ? { waiting_on: input.patch.waiting_on } : {}),
813
+ ...(input.patch.waiting_on_person_id !== undefined ? { waiting_on_person_id: input.patch.waiting_on_person_id } : {}),
804
814
  ...(input.patch.review_at !== undefined ? { review_at: input.patch.review_at } : {}),
805
815
  },
806
816
  };
@@ -818,6 +828,7 @@ export class CanonicalTaskService {
818
828
  if (!isCanonicalTaskPriority(priority))
819
829
  fail('validation_error', 'priority is invalid', 400, { field: 'priority' });
820
830
  const purposeLabel = normalizePurposeLabel(input.purpose_label);
831
+ const waitingOnPersonId = normalizeWaitingOnPersonId(input.waiting_on_person_id);
821
832
  const completionContract = normalizeCompletionContract(input.completion_contract);
822
833
  return {
823
834
  ...(completionContract ? { completion_contract: completionContract } : {}),
@@ -832,6 +843,8 @@ export class CanonicalTaskService {
832
843
  project_codes: normalizeProjectCodes(input.project_codes) ?? [],
833
844
  // Only a label that is there is written, so a store without the column still accepts unlabelled work.
834
845
  ...(purposeLabel ? { purpose_label: purposeLabel } : {}),
846
+ // Likewise only a person that is there is written.
847
+ ...(waitingOnPersonId ? { waiting_on_person_id: waitingOnPersonId } : {}),
835
848
  };
836
849
  }
837
850
  normalizeUpdateInput(input) {
@@ -866,6 +879,8 @@ export class CanonicalTaskService {
866
879
  patch.due_at = normalizeIsoDate(input.due_at, 'due_at');
867
880
  if ('waiting_on' in input)
868
881
  patch.waiting_on = normalizeString(input.waiting_on, 'waiting_on', { max: 500 });
882
+ if ('waiting_on_person_id' in input)
883
+ patch.waiting_on_person_id = normalizeWaitingOnPersonId(input.waiting_on_person_id);
869
884
  if ('review_at' in input)
870
885
  patch.review_at = normalizeIsoDate(input.review_at, 'review_at');
871
886
  if ('source_refs' in input && hasSourceRefsAppend) {
@@ -1052,6 +1067,8 @@ export class CanonicalTaskService {
1052
1067
  response.due_at = null;
1053
1068
  if (response.waiting_on === undefined)
1054
1069
  response.waiting_on = null;
1070
+ if (response.waiting_on_person_id === undefined)
1071
+ response.waiting_on_person_id = null;
1055
1072
  if (response.review_at === undefined)
1056
1073
  response.review_at = null;
1057
1074
  if (response.completed_at === undefined)
@@ -44,6 +44,9 @@ function routeFor(pathname, basePath) {
44
44
  if (segments.length === 4 && noun === 'minutes' && minutesId && action === 'confirm') {
45
45
  return { kind: 'confirm', meetingId, minutesId };
46
46
  }
47
+ if (segments.length === 4 && noun === 'minutes' && minutesId && action === 'withdraw') {
48
+ return { kind: 'withdraw', meetingId, minutesId };
49
+ }
47
50
  if (segments.length === 5 && noun === 'minutes' && minutesId && action === 'versions' && versionId) {
48
51
  return { kind: 'version', meetingId, minutesId, versionId };
49
52
  }
@@ -157,6 +160,13 @@ function confirmInput(body, minutesId, key, fingerprint) {
157
160
  ...idempotencyFields(key, fingerprint)
158
161
  };
159
162
  }
163
+ function withdrawInput(body, minutesId, key, fingerprint) {
164
+ return {
165
+ minutes_id: minutesId,
166
+ expected_revision: body.expected_revision,
167
+ ...idempotencyFields(key, fingerprint)
168
+ };
169
+ }
160
170
  export function createMeetingMinutesHttpHandler(options) {
161
171
  if (!options || !options.store || typeof options.resolveContext !== 'function') {
162
172
  throw new TypeError('store and resolveContext are required');
@@ -202,6 +212,15 @@ export function createMeetingMinutesHttpHandler(options) {
202
212
  }
203
213
  return { statusCode: 200, body: await options.store.confirm_version(confirmInput(body, document.minutes_id, key, fingerprint), context) };
204
214
  }
215
+ case 'withdraw': {
216
+ if (body === undefined)
217
+ throw new MeetingMinutesError('invalid_input', 'Request body is required');
218
+ const detail = await options.store.get_meeting(route.meetingId ?? '', context);
219
+ const document = detail.minutes.find((candidate) => candidate.minutes_id === route.minutesId);
220
+ if (!document)
221
+ throw new MeetingMinutesError('not_found', `Minutes document ${route.minutesId} was not found`);
222
+ return { statusCode: 200, body: await options.store.withdraw_minutes(withdrawInput(body, document.minutes_id, key, fingerprint), context) };
223
+ }
205
224
  case 'version':
206
225
  return {
207
226
  statusCode: 200,
@@ -1132,6 +1132,10 @@ export function createMeetingMinutesStore(options) {
1132
1132
  const meeting = findMeeting(snapshot, document.meeting_id);
1133
1133
  await authorized(authorizer, 'write', meeting, context);
1134
1134
  await authorized(authorizer, 'write', document, context);
1135
+ // Only the person who made the document takes it back (story-meeting-minutes-edit-and-withdraw-ui-v1).
1136
+ if (document.created_actor_id !== principal) {
1137
+ throw new MeetingMinutesError('authorization_denied', 'Only the person who made this minutes document can withdraw it');
1138
+ }
1135
1139
  // Withdrawing twice keeps the first record; a retry is not a second withdrawal.
1136
1140
  if (document.withdrawal && !idempotency.token)
1137
1141
  return detailForCaller(snapshot, meeting, context);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unson/brainbase-mcp",
3
- "version": "0.41.9",
3
+ "version": "0.41.10",
4
4
  "description": "Local-first Brainbase judgment OS that lets supported AI agents reuse a user's approved goals, priorities, decisions, and judgment criteria.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -483,5 +483,5 @@
483
483
  "engines": {
484
484
  "node": ">=20"
485
485
  },
486
- "gitHead": "76bf4ebe18b116379d4687272baecdc4a74dbb35"
486
+ "gitHead": "9ea5345d8da4b0cf825daf73610474dd8903a8f5"
487
487
  }
@@ -136,6 +136,12 @@
136
136
  overflow-y: auto;
137
137
  }
138
138
 
139
+ /* Keep the heading, actions and body together at the top instead of spreading them over the pane. */
140
+ .bb-minutes-detail-pane,
141
+ .bb-minutes-document {
142
+ align-content: start;
143
+ }
144
+
139
145
  .bb-minutes-list-pane {
140
146
  grid-template-rows: auto minmax(0, 1fr);
141
147
  gap: 0;
@@ -146,7 +152,8 @@
146
152
  .bb-minutes-detail-head,
147
153
  .bb-minutes-document-head,
148
154
  .bb-minutes-actions,
149
- .bb-minutes-version-band {
155
+ .bb-minutes-version-actions,
156
+ .bb-minutes-version-meta {
150
157
  display: flex;
151
158
  gap: 8px;
152
159
  align-items: center;
@@ -328,12 +335,66 @@
328
335
  margin: 0;
329
336
  }
330
337
 
331
- .bb-minutes-version-band {
338
+ .bb-minutes-version-actions {
339
+ justify-content: flex-start;
340
+ margin: 0;
341
+ }
342
+
343
+ /* Which version is shown sits quietly below the body (story-meeting-minutes-edit-and-withdraw-ui-v1 AC-06). */
344
+ .bb-minutes-version-meta {
345
+ justify-content: flex-start;
332
346
  margin: 0;
347
+ padding-top: 8px;
348
+ border-top: 1px solid var(--bb-color-border);
349
+ color: var(--bb-color-muted);
350
+ font-size: 12px;
351
+ }
352
+
353
+ .bb-minutes-withdrawn,
354
+ .bb-minutes-withdraw-guard,
355
+ .bb-minutes-edit-note {
356
+ margin: 0;
357
+ color: var(--bb-color-muted);
358
+ font-size: 13px;
359
+ overflow-wrap: anywhere;
360
+ }
361
+
362
+ .bb-minutes-withdrawn {
333
363
  padding: 8px 12px;
364
+ background: var(--bb-color-surface);
334
365
  border: 1px solid var(--bb-color-border);
335
- border-radius: 8px;
336
- font-size: 13px;
366
+ border-radius: var(--bb-radius-sm, 4px);
367
+ }
368
+
369
+ .bb-minutes-withdraw-confirm {
370
+ display: grid;
371
+ gap: 8px;
372
+ padding: 12px;
373
+ border: 1px solid var(--bb-color-danger);
374
+ border-radius: var(--bb-radius-sm, 4px);
375
+ background: var(--bb-color-paper);
376
+ }
377
+
378
+ .bb-minutes-withdraw-confirm p,
379
+ .bb-minutes-withdraw-confirm ul {
380
+ margin: 0;
381
+ }
382
+
383
+ .bb-minutes-withdraw-title {
384
+ font-weight: 600;
385
+ }
386
+
387
+ .bb-minutes-edit-form {
388
+ display: grid;
389
+ gap: 8px;
390
+ }
391
+
392
+ .bb-minutes-edit-form textarea {
393
+ width: 100%;
394
+ box-sizing: border-box;
395
+ min-height: 16rem;
396
+ font: inherit;
397
+ line-height: 1.6;
337
398
  }
338
399
 
339
400
  .bb-minutes-version-content {
@@ -419,6 +480,18 @@
419
480
  color: var(--bb-color-action-text);
420
481
  }
421
482
 
483
+ .bb-minutes-button.is-quiet {
484
+ border-color: var(--bb-color-border);
485
+ color: var(--bb-color-muted);
486
+ }
487
+
488
+ .bb-minutes-button.is-danger,
489
+ .bb-minutes-button.is-danger:hover {
490
+ border-color: var(--bb-color-danger);
491
+ background: var(--bb-color-danger);
492
+ color: var(--bb-color-action-text);
493
+ }
494
+
422
495
  .bb-minutes-button:disabled {
423
496
  cursor: wait;
424
497
  opacity: 0.6;
@@ -465,3 +538,77 @@
465
538
  grid-template-columns: 1fr;
466
539
  }
467
540
  }
541
+
542
+ /* この会議から: project, tasks, decisions, storage and sharing. */
543
+ .bb-minutes-connections {
544
+ display: grid;
545
+ align-self: start;
546
+ align-content: start;
547
+ gap: 4px;
548
+ margin: 8px 0 4px;
549
+ padding: 8px 12px;
550
+ border: 1px solid var(--bb-color-border);
551
+ border-radius: var(--bb-radius-md, 6px);
552
+ background: var(--bb-color-surface);
553
+ }
554
+
555
+ .bb-minutes-connections-title {
556
+ margin: 0;
557
+ color: var(--bb-color-muted);
558
+ font-size: 12px;
559
+ font-weight: 700;
560
+ }
561
+
562
+ .bb-minutes-connection-list {
563
+ display: grid;
564
+ gap: 2px;
565
+ }
566
+
567
+ .bb-minutes-connection-row {
568
+ display: grid;
569
+ grid-template-columns: 7em minmax(0, 1fr);
570
+ gap: 8px;
571
+ align-items: baseline;
572
+ font-size: 13px;
573
+ line-height: 1.5;
574
+ }
575
+
576
+ .bb-minutes-connection-label {
577
+ color: var(--bb-color-muted);
578
+ font-size: 12px;
579
+ }
580
+
581
+ .bb-minutes-connection-items {
582
+ display: flex;
583
+ flex-wrap: wrap;
584
+ gap: 2px 12px;
585
+ margin: 0;
586
+ padding: 0;
587
+ list-style: none;
588
+ min-width: 0;
589
+ }
590
+
591
+ .bb-minutes-connection-items li {
592
+ min-width: 0;
593
+ overflow-wrap: anywhere;
594
+ }
595
+
596
+ .bb-minutes-connection-link {
597
+ color: var(--bb-color-brand-indigo);
598
+ }
599
+
600
+ .bb-minutes-connection-detail {
601
+ margin-left: 6px;
602
+ color: var(--bb-color-muted);
603
+ font-size: 12px;
604
+ }
605
+
606
+ .bb-minutes-connection-note {
607
+ margin: 0;
608
+ color: var(--bb-color-muted);
609
+ overflow-wrap: anywhere;
610
+ }
611
+
612
+ .bb-minutes-connection-note.is-unavailable {
613
+ color: var(--bb-color-warning);
614
+ }
@@ -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