@unson/brainbase-mcp 0.41.13 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui/inbox.js ADDED
@@ -0,0 +1,578 @@
1
+ /*
2
+ * 受信箱 (story-inbox-stage1-v1, docs/specs/inbox-stage1.md).
3
+ *
4
+ * What is waiting for the viewer — to answer, approve, sort or take in, and
5
+ * tasks — on one screen with two tabs, 対応待ち and お知らせ. The part never
6
+ * reads anything by itself: the host passes a port (`readInbox`,
7
+ * `setDisposition`, optional `runAction` and `readNotices`). Each kind is
8
+ * read on its own; a kind that could not be read says so and is never shown
9
+ * as zero. あとで and 片付けた belong to the host (the inbox is the canonical
10
+ * place for them), and a row's buttons call the host's own operation: the part
11
+ * has no write path of its own.
12
+ *
13
+ * The address keeps only the tab and the list being shown; titles, details
14
+ * and item ids never enter it.
15
+ */
16
+
17
+ import {
18
+ makeWorkspaceElement,
19
+ workspaceButton,
20
+ workspaceErrorCard,
21
+ workspaceNotice,
22
+ workspacePageHeader,
23
+ } from './workspace-kit.js';
24
+
25
+ export const INBOX_UI_CONTRACT_VERSION = 'brainbase.inbox-ui.v1';
26
+
27
+ export const INBOX_GROUP_LABELS = Object.freeze({
28
+ answer: '答える・決める',
29
+ approve: '承認する',
30
+ triage: '振り分ける・取り込む',
31
+ task: 'タスク',
32
+ });
33
+ const GROUPS = Object.keys(INBOX_GROUP_LABELS);
34
+ const TABS = ['waiting', 'notices'];
35
+ const VIEWS = ['waiting', 'later', 'cleared'];
36
+
37
+ const el = makeWorkspaceElement;
38
+
39
+ // Why a read failed, in the viewer's words; the code stays beside it for support.
40
+ const REASON_LABELS = Object.freeze({
41
+ upstream_unavailable: '元の記録を読めません',
42
+ forbidden: '読む権限がありません',
43
+ unauthorized: 'ログインし直してください',
44
+ store_unavailable: '受信箱の状態を保存できません',
45
+ request_failed: '通信に失敗しました',
46
+ response_invalid: '応答の形が正しくありません',
47
+ });
48
+
49
+ function reasonText(reason) {
50
+ const code = String(reason ?? 'unknown');
51
+ return REASON_LABELS[code] ? `${REASON_LABELS[code]}(${code})` : code;
52
+ }
53
+
54
+ function textOf(value) {
55
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
56
+ }
57
+
58
+ function timeOf(value) {
59
+ const time = value ? new Date(value).getTime() : Number.NaN;
60
+ return Number.isNaN(time) ? null : time;
61
+ }
62
+
63
+ function unitsOf(item) {
64
+ return Number.isInteger(item?.units) && item.units >= 0 ? item.units : 1;
65
+ }
66
+
67
+ function normalizeRoute(route = {}) {
68
+ return {
69
+ tab: TABS.includes(route.tab) ? route.tab : 'waiting',
70
+ view: VIEWS.includes(route.view) ? route.view : 'waiting',
71
+ };
72
+ }
73
+
74
+ /** Reads `tab` and `view` from the address. Unknown values fall back to 対応待ち. */
75
+ export function parseInboxRoute(params) {
76
+ return normalizeRoute({ tab: params.get('tab'), view: params.get('view') });
77
+ }
78
+
79
+ /** The shell's hash target for a route. Anything else in the route is dropped. */
80
+ export function inboxRouteTarget(route) {
81
+ const normalized = normalizeRoute(route);
82
+ const params = new URLSearchParams();
83
+ if (normalized.tab !== 'waiting') params.set('tab', normalized.tab);
84
+ else if (normalized.view !== 'waiting') params.set('view', normalized.view);
85
+ const query = params.toString();
86
+ return query ? `inbox?${query}` : 'inbox';
87
+ }
88
+
89
+ /** Blocking first, then the nearest due date (none last), then the newest arrival. */
90
+ export function sortInboxItems(items) {
91
+ return [...(Array.isArray(items) ? items : [])].sort((left, right) => {
92
+ const blocking = Number(right.blocking === true) - Number(left.blocking === true);
93
+ if (blocking !== 0) return blocking;
94
+ const leftDue = timeOf(left.dueAt);
95
+ const rightDue = timeOf(right.dueAt);
96
+ if (leftDue !== rightDue) {
97
+ if (leftDue === null) return 1;
98
+ if (rightDue === null) return -1;
99
+ return leftDue - rightDue;
100
+ }
101
+ return (timeOf(right.receivedAt) ?? 0) - (timeOf(left.receivedAt) ?? 0);
102
+ });
103
+ }
104
+
105
+ /** The disposition that still applies to an item now: same revision, and a later not yet passed. */
106
+ function activeDisposition(item, dispositions, now) {
107
+ const disposition = dispositions.get(item.id);
108
+ if (!disposition) return null;
109
+ if ((disposition.revision ?? null) !== (item.revision ?? null)) return null;
110
+ if (disposition.state === 'later') {
111
+ const until = timeOf(disposition.until);
112
+ return until !== null && until > now.getTime() ? disposition : null;
113
+ }
114
+ return disposition.state === 'cleared' ? disposition : null;
115
+ }
116
+
117
+ function dispositionMap(list) {
118
+ const map = new Map();
119
+ for (const disposition of Array.isArray(list) ? list : []) {
120
+ if (textOf(disposition?.itemId)) map.set(disposition.itemId, disposition);
121
+ }
122
+ return map;
123
+ }
124
+
125
+ function readableItems(result) {
126
+ const readable = new Set((result.sources ?? []).filter((source) => source.status === 'ok').map((source) => source.kind));
127
+ return (Array.isArray(result.items) ? result.items : []).filter((item) => readable.has(item.kind) && GROUPS.includes(item.group));
128
+ }
129
+
130
+ /**
131
+ * The 対応待ち count for the left menu: what is shown (hidden and cleared rows
132
+ * left out, a summary row counts as its own number), from the kinds that could
133
+ * be read. `count` is null when the inbox itself could not be read.
134
+ */
135
+ export function countInboxWaiting(result, now = new Date()) {
136
+ if (result?.status !== 'ok') return { count: null, unreadable: [] };
137
+ const dispositions = dispositionMap(result.dispositions);
138
+ const count = readableItems(result)
139
+ .filter((item) => !activeDisposition(item, dispositions, now))
140
+ .reduce((sum, item) => sum + unitsOf(item), 0);
141
+ const unreadable = (result.sources ?? []).filter((source) => source.status === 'unavailable').map((source) => source.label);
142
+ return { count, unreadable };
143
+ }
144
+
145
+ function at(base, dayOffset, hour) {
146
+ const date = new Date(base.getFullYear(), base.getMonth(), base.getDate() + dayOffset, hour, 0, 0, 0);
147
+ return date;
148
+ }
149
+
150
+ /** あとで: this evening (only before 18:00), tomorrow morning, next Monday morning — by the viewer's clock. */
151
+ export function inboxLaterChoices(now = new Date()) {
152
+ const choices = [];
153
+ const evening = at(now, 0, 18);
154
+ if (evening.getTime() > now.getTime()) choices.push({ id: 'evening', label: '今日の夕方', until: evening });
155
+ choices.push({ id: 'tomorrow', label: '明日の朝', until: at(now, 1, 9) });
156
+ const toMonday = ((8 - now.getDay()) % 7) || 7;
157
+ choices.push({ id: 'monday', label: '来週の月曜の朝', until: at(now, toMonday, 9) });
158
+ return choices;
159
+ }
160
+
161
+ export const INBOX_DISPOSITIONS_KEY = 'brainbase.inbox.dispositions.v1';
162
+ const CLEARED_KEPT_MS = 30 * 86_400_000;
163
+
164
+ /**
165
+ * 片付けた・あとで kept in the viewer's browser (stage 1: the inbox has no
166
+ * canonical store yet; stage 4 moves them there with the Slack sync). One
167
+ * key per person, because a browser can be shared by several accounts. A
168
+ * storage that cannot be reached reads as nothing kept and fails every write
169
+ * with `store_unavailable`, so the screen puts the row back.
170
+ */
171
+ export function createLocalInboxDispositions(storage, { personId = null, key = INBOX_DISPOSITIONS_KEY, now = () => new Date() } = {}) {
172
+ const storageKey = textOf(personId) ? `${key}:${personId}` : key;
173
+ const read = () => {
174
+ try {
175
+ const raw = storage?.getItem?.(storageKey);
176
+ const value = raw ? JSON.parse(raw) : [];
177
+ return Array.isArray(value) ? value.filter((entry) => textOf(entry?.itemId)) : [];
178
+ } catch {
179
+ return [];
180
+ }
181
+ };
182
+ const current = (entries) => {
183
+ const time = now().getTime();
184
+ // A passed あとで and an old 片付けた are dropped so the key does not grow.
185
+ return entries.filter((entry) => (entry.state === 'later'
186
+ ? (timeOf(entry.until) ?? 0) > time
187
+ : entry.state === 'cleared' && time - (timeOf(entry.at) ?? 0) < CLEARED_KEPT_MS));
188
+ };
189
+ return Object.freeze({
190
+ list: () => current(read()),
191
+ set(itemId, change) {
192
+ if (!storage || typeof storage.setItem !== 'function') return { status: 'unavailable', reason: 'store_unavailable' };
193
+ const others = current(read()).filter((entry) => entry.itemId !== itemId);
194
+ const next = change?.state === 'later' || change?.state === 'cleared'
195
+ ? [...others, { itemId, revision: change.revision ?? null, state: change.state, until: change.state === 'later' ? change.until : null, at: now().toISOString() }]
196
+ : others;
197
+ try {
198
+ storage.setItem(storageKey, JSON.stringify(next));
199
+ return { status: 'ok' };
200
+ } catch {
201
+ return { status: 'unavailable', reason: 'store_unavailable' };
202
+ }
203
+ },
204
+ });
205
+ }
206
+
207
+ /** A port whose 片付けた・あとで live in `store` (see createLocalInboxDispositions). */
208
+ export function withLocalInboxDispositions(reader, store) {
209
+ const port = {
210
+ async readInbox() {
211
+ const result = await reader.readInbox();
212
+ return result?.status === 'ok' ? { ...result, dispositions: store.list() } : result;
213
+ },
214
+ async setDisposition(itemId, change) {
215
+ return store.set(itemId, change);
216
+ },
217
+ };
218
+ if (typeof reader.runAction === 'function') port.runAction = (itemId, actionId) => reader.runAction(itemId, actionId);
219
+ if (typeof reader.readNotices === 'function') port.readNotices = () => reader.readNotices();
220
+ return port;
221
+ }
222
+
223
+ function formatShort(value) {
224
+ const time = timeOf(value);
225
+ if (time === null) return null;
226
+ const date = new Date(time);
227
+ return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`;
228
+ }
229
+
230
+ function relativeTime(value, now) {
231
+ const time = timeOf(value);
232
+ if (time === null) return null;
233
+ const minutes = Math.floor((now.getTime() - time) / 60_000);
234
+ if (minutes < 1) return 'たった今';
235
+ if (minutes < 60) return `${minutes}分前`;
236
+ const hours = Math.floor(minutes / 60);
237
+ if (hours < 24) return `${hours}時間前`;
238
+ const days = Math.floor(hours / 24);
239
+ return days < 30 ? `${days}日前` : formatShort(value);
240
+ }
241
+
242
+ export function createInboxUI({
243
+ root,
244
+ page,
245
+ document: explicitDocument,
246
+ port,
247
+ route: initialRoute,
248
+ onRouteChange,
249
+ onNavigate,
250
+ onCount,
251
+ now = () => new Date(),
252
+ } = {}) {
253
+ if (!root) throw new TypeError('root is required');
254
+ if (!port || typeof port.readInbox !== 'function' || typeof port.setDisposition !== 'function') {
255
+ throw new TypeError('port is required');
256
+ }
257
+ const doc = explicitDocument ?? (typeof document === 'undefined' ? null : document);
258
+ if (!doc || typeof doc.createElement !== 'function') throw new Error('document_unavailable');
259
+ const crumbs = Array.isArray(page?.crumbs) ? page.crumbs : ['受信箱'];
260
+ const readNow = () => (typeof now === 'function' ? now() : now);
261
+
262
+ const state = {
263
+ route: normalizeRoute(initialRoute),
264
+ phase: 'idle',
265
+ result: null,
266
+ reason: null,
267
+ dispositions: new Map(),
268
+ laterOpen: null,
269
+ notice: null,
270
+ notices: null,
271
+ destroyed: false,
272
+ };
273
+ let generation = 0;
274
+
275
+ function go(route) {
276
+ const next = normalizeRoute({ ...state.route, ...route });
277
+ if (typeof onRouteChange === 'function') onRouteChange(next);
278
+ else applyRoute(next);
279
+ }
280
+
281
+ function reportCount() {
282
+ if (typeof onCount !== 'function') return;
283
+ onCount(state.result ? countInboxWaiting({ ...state.result, dispositions: [...state.dispositions.values()] }, readNow()) : { count: null, unreadable: [] });
284
+ }
285
+
286
+ async function loadNotices() {
287
+ if (typeof port.readNotices !== 'function') {
288
+ state.notices = { phase: 'not_collected' };
289
+ return;
290
+ }
291
+ let result;
292
+ try {
293
+ result = await port.readNotices();
294
+ } catch (error) {
295
+ result = { status: 'unavailable', reason: error instanceof Error ? error.message : 'request_failed' };
296
+ }
297
+ state.notices = result?.status === 'ok' && Array.isArray(result.items)
298
+ ? { phase: 'ok', items: result.items }
299
+ : { phase: 'unavailable', reason: result?.reason ?? 'response_invalid' };
300
+ }
301
+
302
+ async function load() {
303
+ if (state.destroyed) return;
304
+ const current = ++generation;
305
+ // The previous rows stay on screen while the next read runs.
306
+ state.phase = 'loading';
307
+ render();
308
+ let result;
309
+ try {
310
+ result = await port.readInbox();
311
+ } catch (error) {
312
+ result = { status: 'unavailable', reason: error instanceof Error ? error.message : 'request_failed' };
313
+ }
314
+ if (state.route.tab === 'notices' && state.notices === null) await loadNotices();
315
+ if (current !== generation || state.destroyed) return;
316
+ if (result?.status === 'ok' && Array.isArray(result.sources) && Array.isArray(result.items)) {
317
+ state.result = result;
318
+ state.dispositions = dispositionMap(result.dispositions);
319
+ state.phase = 'ready';
320
+ state.reason = null;
321
+ } else {
322
+ state.result = null;
323
+ state.phase = 'error';
324
+ state.reason = result?.reason ?? 'response_invalid';
325
+ }
326
+ render();
327
+ reportCount();
328
+ }
329
+
330
+ function applyRoute(route) {
331
+ state.route = normalizeRoute(route);
332
+ state.laterOpen = null;
333
+ if (state.route.tab === 'notices' && state.notices === null) {
334
+ void loadNotices().then(() => render());
335
+ }
336
+ render();
337
+ }
338
+
339
+ async function dispose(item, change, failureTitle) {
340
+ const previous = state.dispositions.get(item.id) ?? null;
341
+ if (change.state === 'open') state.dispositions.delete(item.id);
342
+ else state.dispositions.set(item.id, { itemId: item.id, revision: change.revision, state: change.state, until: change.until ?? null, at: readNow().toISOString() });
343
+ state.laterOpen = null;
344
+ state.notice = null;
345
+ render();
346
+ reportCount();
347
+ let result;
348
+ try {
349
+ result = await port.setDisposition(item.id, change);
350
+ } catch (error) {
351
+ result = { status: 'unavailable', reason: error instanceof Error ? error.message : 'request_failed' };
352
+ }
353
+ if (state.destroyed || result?.status === 'ok') return;
354
+ if (previous) state.dispositions.set(item.id, previous);
355
+ else state.dispositions.delete(item.id);
356
+ state.notice = `${failureTitle}。理由: ${reasonText(result?.reason ?? 'response_invalid')}。`;
357
+ render();
358
+ reportCount();
359
+ }
360
+
361
+ async function runAction(item, action) {
362
+ let result;
363
+ try {
364
+ result = await port.runAction(item.id, action.id);
365
+ } catch (error) {
366
+ result = { status: 'unavailable', reason: error instanceof Error ? error.message : 'request_failed' };
367
+ }
368
+ if (state.destroyed) return;
369
+ if (result?.status === 'ok') {
370
+ state.notice = null;
371
+ await load();
372
+ return;
373
+ }
374
+ state.notice = `「${action.label}」を実行できませんでした。理由: ${reasonText(result?.reason ?? 'response_invalid')}。`;
375
+ render();
376
+ }
377
+
378
+ function openItem(item, event) {
379
+ if (item.clearOnOpen === true) void dispose(item, { state: 'cleared', revision: item.revision ?? null }, '開いた行を片付けられませんでした');
380
+ const href = textOf(item.href);
381
+ if (href && typeof onNavigate === 'function') {
382
+ event?.preventDefault?.();
383
+ onNavigate(href);
384
+ }
385
+ }
386
+
387
+ function renderTabs(waitingCount) {
388
+ const bar = el(doc, 'div', { className: 'bb-inbox-tabs', attrs: { role: 'tablist', 'aria-label': '受信箱' } });
389
+ for (const [id, label] of [['waiting', '対応待ち'], ['notices', 'お知らせ']]) {
390
+ const selected = state.route.tab === id;
391
+ const tab = el(doc, 'button', {
392
+ className: `bb-inbox-tab${selected ? ' is-selected' : ''}`,
393
+ text: label,
394
+ attrs: { type: 'button', role: 'tab', 'aria-selected': selected ? 'true' : 'false' },
395
+ });
396
+ if (id === 'waiting' && Number.isInteger(waitingCount) && waitingCount > 0) {
397
+ tab.append(el(doc, 'span', { className: 'bb-inbox-count', text: waitingCount, attrs: { 'aria-label': `${waitingCount}件` } }));
398
+ }
399
+ tab.addEventListener('click', () => go({ tab: id, view: 'waiting' }));
400
+ bar.append(tab);
401
+ }
402
+ return bar;
403
+ }
404
+
405
+ function renderLaterMenu(item) {
406
+ const menu = el(doc, 'div', { className: 'bb-inbox-later-menu', attrs: { role: 'group', 'aria-label': 'あとでの時刻' } });
407
+ for (const choice of inboxLaterChoices(readNow())) {
408
+ menu.append(workspaceButton(doc, {
409
+ text: choice.label,
410
+ variant: 'quiet',
411
+ onClick: () => void dispose(item, { state: 'later', until: choice.until.toISOString(), revision: item.revision ?? null }, 'あとでにできませんでした'),
412
+ }));
413
+ }
414
+ return menu;
415
+ }
416
+
417
+ function renderRow(item, view) {
418
+ const row = el(doc, 'div', { className: `bb-inbox-row${item.blocking === true ? ' is-blocking' : ''}`, attrs: { role: 'listitem', 'data-inbox-item': item.id } });
419
+ const main = el(doc, 'div', { className: 'bb-inbox-row-main' });
420
+ const href = textOf(item.href);
421
+ const title = el(doc, href ? 'a' : 'span', { className: 'bb-inbox-title', text: item.title, attrs: href ? { href } : {} });
422
+ if (href) title.addEventListener('click', (event) => openItem(item, event));
423
+ main.append(title);
424
+ const meta = el(doc, 'div', { className: 'bb-inbox-meta' });
425
+ if (item.blocking === true) meta.append(el(doc, 'span', { className: 'bb-inbox-blocking', text: '止めています' }));
426
+ const due = formatShort(item.dueAt);
427
+ if (due) meta.append(el(doc, 'span', { className: 'bb-inbox-due', text: `期限 ${due}` }));
428
+ const received = relativeTime(item.receivedAt, readNow());
429
+ if (received) meta.append(el(doc, 'span', { text: received, attrs: { title: formatShort(item.receivedAt) ?? undefined } }));
430
+ const disposition = state.dispositions.get(item.id);
431
+ if (view === 'later' && disposition?.until) meta.append(el(doc, 'span', { className: 'bb-inbox-until', text: `${formatShort(disposition.until)}に戻ります` }));
432
+ if (textOf(item.detail)) main.append(el(doc, 'p', { className: 'bb-inbox-detail', text: item.detail }));
433
+ if (meta.children?.length !== 0) main.append(meta);
434
+ row.append(main);
435
+
436
+ const controls = el(doc, 'div', { className: 'bb-inbox-row-actions' });
437
+ if (view === 'waiting') {
438
+ if (typeof port.runAction === 'function') {
439
+ for (const action of Array.isArray(item.actions) ? item.actions : []) {
440
+ if (!textOf(action?.id) || !textOf(action?.label)) continue;
441
+ controls.append(workspaceButton(doc, { text: action.label, variant: action.tone === 'primary' ? 'primary' : action.tone === 'danger' ? 'danger' : 'default', onClick: () => void runAction(item, action) }));
442
+ }
443
+ }
444
+ controls.append(
445
+ workspaceButton(doc, {
446
+ text: 'あとで',
447
+ variant: 'quiet',
448
+ attrs: { 'aria-expanded': state.laterOpen === item.id ? 'true' : 'false', 'aria-haspopup': 'true' },
449
+ onClick: () => {
450
+ state.laterOpen = state.laterOpen === item.id ? null : item.id;
451
+ render();
452
+ },
453
+ }),
454
+ workspaceButton(doc, { text: '片付けた', variant: 'quiet', onClick: () => void dispose(item, { state: 'cleared', revision: item.revision ?? null }, '片付けられませんでした') }),
455
+ );
456
+ } else {
457
+ controls.append(workspaceButton(doc, { text: '対応待ちに戻す', variant: 'quiet', onClick: () => void dispose(item, { state: 'open' }, '対応待ちに戻せませんでした') }));
458
+ }
459
+ row.append(controls);
460
+ if (state.laterOpen === item.id) row.append(renderLaterMenu(item));
461
+ return row;
462
+ }
463
+
464
+ function sourceLine(source) {
465
+ if (source.status === 'not_connected') {
466
+ return el(doc, 'p', { className: 'bb-inbox-source is-not-connected', text: `${source.label}は未接続です。` });
467
+ }
468
+ return el(doc, 'p', { className: 'bb-inbox-source is-unavailable', text: `${source.label}を読めませんでした。理由: ${reasonText(source.reason)}。0件ではありません。`, attrs: { role: 'status' } });
469
+ }
470
+
471
+ function partition(now) {
472
+ const shown = { waiting: [], later: [], cleared: [] };
473
+ for (const item of readableItems(state.result)) {
474
+ const disposition = activeDisposition(item, state.dispositions, now);
475
+ shown[disposition ? disposition.state : 'waiting'].push(item);
476
+ }
477
+ return shown;
478
+ }
479
+
480
+ function renderWaiting(shown) {
481
+ const view = el(doc, 'div', { className: 'bb-inbox-waiting' });
482
+ const sources = state.result.sources;
483
+ let anyContent = false;
484
+ for (const group of GROUPS) {
485
+ const rows = sortInboxItems(shown.waiting.filter((item) => item.group === group));
486
+ const problems = sources.filter((source) => source.group === group && source.status !== 'ok');
487
+ if (rows.length === 0 && problems.length === 0) continue;
488
+ anyContent = true;
489
+ const section = el(doc, 'section', { className: 'bb-inbox-group', attrs: { 'data-group': group, 'aria-label': INBOX_GROUP_LABELS[group] } });
490
+ section.append(el(doc, 'h2', { className: 'bb-inbox-group-title', text: INBOX_GROUP_LABELS[group] }));
491
+ if (rows.length > 0) {
492
+ const list = el(doc, 'div', { className: 'bb-inbox-list', attrs: { role: 'list' } });
493
+ for (const item of rows) list.append(renderRow(item, 'waiting'));
494
+ section.append(list);
495
+ }
496
+ for (const source of problems) section.append(sourceLine(source));
497
+ view.append(section);
498
+ }
499
+ const allRead = sources.every((source) => source.status !== 'unavailable');
500
+ if (!anyContent && allRead) view.append(el(doc, 'p', { className: 'bb-inbox-empty', text: '待っているものはありません。' }));
501
+ const uncollected = (Array.isArray(state.result.uncollected) ? state.result.uncollected : []).filter(textOf);
502
+ if (uncollected.length > 0) {
503
+ view.append(el(doc, 'p', { className: 'bb-inbox-uncollected', text: `まだ集めていない種類があります(${uncollected.join('・')})。件数には入っていません。` }));
504
+ }
505
+ const links = el(doc, 'div', { className: 'bb-inbox-elsewhere' });
506
+ if (shown.later.length > 0) links.append(workspaceButton(doc, { text: `あとで ${shown.later.length}件`, variant: 'quiet', onClick: () => go({ view: 'later' }) }));
507
+ if (shown.cleared.length > 0) links.append(workspaceButton(doc, { text: `片付けた ${shown.cleared.length}件`, variant: 'quiet', onClick: () => go({ view: 'cleared' }) }));
508
+ if (links.children.length > 0) view.append(links);
509
+ return view;
510
+ }
511
+
512
+ function renderSet(shown, which) {
513
+ const view = el(doc, 'div', { className: 'bb-inbox-set' });
514
+ view.append(workspaceButton(doc, { text: '対応待ちへ', variant: 'quiet', onClick: () => go({ view: 'waiting' }) }));
515
+ view.append(el(doc, 'h2', { className: 'bb-inbox-group-title', text: which === 'later' ? 'あとで' : '片付けた' }));
516
+ const rows = which === 'later'
517
+ ? [...shown.later].sort((left, right) => (timeOf(state.dispositions.get(left.id)?.until) ?? 0) - (timeOf(state.dispositions.get(right.id)?.until) ?? 0))
518
+ : [...shown.cleared].sort((left, right) => (timeOf(state.dispositions.get(right.id)?.at) ?? 0) - (timeOf(state.dispositions.get(left.id)?.at) ?? 0));
519
+ if (rows.length === 0) {
520
+ view.append(el(doc, 'p', { className: 'bb-inbox-empty', text: which === 'later' ? 'あとでにしたものはありません。' : '片付けたものはありません。' }));
521
+ return view;
522
+ }
523
+ const list = el(doc, 'div', { className: 'bb-inbox-list', attrs: { role: 'list' } });
524
+ for (const item of rows) list.append(renderRow(item, which));
525
+ view.append(list);
526
+ return view;
527
+ }
528
+
529
+ function renderNotices() {
530
+ const view = el(doc, 'div', { className: 'bb-inbox-notices' });
531
+ const notices = state.notices;
532
+ if (!notices || notices.phase === 'not_collected') {
533
+ view.append(el(doc, 'p', { className: 'bb-inbox-uncollected', text: 'お知らせはまだ集めていません。AIに任せた仕事の終わり・議事録ができたこと・申請の結果は、これから集めます。' }));
534
+ } else if (notices.phase === 'unavailable') {
535
+ view.append(workspaceErrorCard(doc, { title: 'お知らせを読めませんでした', message: `理由: ${reasonText(notices.reason)}。0件ではありません。` }));
536
+ } else if (notices.items.length === 0) {
537
+ view.append(el(doc, 'p', { className: 'bb-inbox-empty', text: 'お知らせはありません。' }));
538
+ }
539
+ return view;
540
+ }
541
+
542
+ function render() {
543
+ if (state.destroyed) return;
544
+ const current = readNow();
545
+ const shown = state.result ? partition(current) : null;
546
+ const waitingCount = shown ? shown.waiting.reduce((sum, item) => sum + unitsOf(item), 0) : null;
547
+ const view = el(doc, 'div', { className: 'bb-inbox', attrs: { 'data-contract-version': INBOX_UI_CONTRACT_VERSION } });
548
+ view.append(workspacePageHeader(doc, { crumbs, title: '受信箱', source: page?.source ?? undefined }));
549
+ view.append(renderTabs(waitingCount));
550
+ if (state.phase === 'loading') view.append(el(doc, 'div', { className: 'bb-inbox-loading', text: '読み込んでいます', attrs: { role: 'status' } }));
551
+ if (state.notice) view.append(workspaceNotice(doc, { label: 'できませんでした', text: state.notice, tone: 'warning', role: 'alert' }));
552
+ if (state.route.tab === 'notices') {
553
+ view.append(renderNotices());
554
+ } else if (state.phase === 'error' && !shown) {
555
+ view.append(workspaceErrorCard(doc, { title: '受信箱を読めませんでした', message: `理由: ${reasonText(state.reason)}。0件ではありません。`, onRetry: () => void load() }));
556
+ } else if (shown) {
557
+ view.append(state.route.view === 'waiting' ? renderWaiting(shown) : renderSet(shown, state.route.view));
558
+ }
559
+ root.replaceChildren(view);
560
+ }
561
+
562
+ const controller = {
563
+ load,
564
+ applyRoute,
565
+ get state() {
566
+ return { route: { ...state.route }, phase: state.phase, result: state.result, dispositions: [...state.dispositions.values()] };
567
+ },
568
+ destroy() {
569
+ state.destroyed = true;
570
+ generation += 1;
571
+ root.replaceChildren();
572
+ },
573
+ };
574
+ render();
575
+ return controller;
576
+ }
577
+
578
+ export default createInboxUI;