@takagaki/cortex-decisions-viewer 0.12.29 → 0.12.30

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.
@@ -0,0 +1,751 @@
1
+ "use strict";
2
+ // ---------------------------------------------------------------------------
3
+ // 操作タブの仮置き(pending ops・#388)
4
+ //
5
+ // 「ビルド待ちの操作はすべて即時反映」(決定ログ 2026-09-03)を、操作タブの全操作に
6
+ // 共通の 1 部品で当てる。**能力ごとに個別の仕組みを持たない**(Backlog だけ直して
7
+ // Figma に残した前例を繰り返さない)。
8
+ //
9
+ // 流れ: 保存が成功 → `pendingOpOf` でエントリを作り localStorage に置く →
10
+ // 描画は「本物の設定(4層合成)+仮置き」を `applyPendingOps` で合成して出す →
11
+ // 本物が追いついたら `prunePendingOps` が捨てる。
12
+ //
13
+ // **ここは純関数と定数だけを置く**(render.ts 冒頭の規約と同じ)。画面側(クライアントJS)は
14
+ // toString() で実体を流し込んで使い、テストは同じ実体を直接呼ぶ。
15
+ // **クロージャを持たないこと。** 参照してよいのは、クライアント側にも同名で注入している
16
+ // 定数(PENDING_OPS_* と PENDING_KINDS)だけ。
17
+ // ⚠ 定数そのものを export しない(CommonJS で `exports.X` に書き換わり、流し込んだ先で落ちる)。
18
+ // テストから読む値は下の PENDING_OPS_LIMITS(入れ物)経由。
19
+ // ---------------------------------------------------------------------------
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.PENDING_OPS_LIMITS = void 0;
22
+ exports.pendingKindsTable = pendingKindsTable;
23
+ exports.pendingOpsKey = pendingOpsKey;
24
+ exports.pendingSlackChannelId = pendingSlackChannelId;
25
+ exports.pendingGoogleChatSpaceRef = pendingGoogleChatSpaceRef;
26
+ exports.pendingRepo = pendingRepo;
27
+ exports.pendingFigmaKey = pendingFigmaKey;
28
+ exports.pendingDriveFolderId = pendingDriveFolderId;
29
+ exports.backlogTargetOf = backlogTargetOf;
30
+ exports.pendingIssuesRef = pendingIssuesRef;
31
+ exports.pendingLabel = pendingLabel;
32
+ exports.pendingOpOf = pendingOpOf;
33
+ exports.sanitizePendingOps = sanitizePendingOps;
34
+ exports.rememberPendingOp = rememberPendingOp;
35
+ exports.pendingSiteTimeMs = pendingSiteTimeMs;
36
+ exports.pendingOpBuildCaughtUp = pendingOpBuildCaughtUp;
37
+ exports.pendingLiveRowMatches = pendingLiveRowMatches;
38
+ exports.pendingOpLiveAgrees = pendingOpLiveAgrees;
39
+ exports.pendingCountDelta = pendingCountDelta;
40
+ exports.prunePendingOps = prunePendingOps;
41
+ exports.pendingExternalMatches = pendingExternalMatches;
42
+ exports.pendingExternalRow = pendingExternalRow;
43
+ exports.pendingInternalOf = pendingInternalOf;
44
+ exports.applyPendingOps = applyPendingOps;
45
+ exports.pendingCapsOf = pendingCapsOf;
46
+ exports.pendingRowNote = pendingRowNote;
47
+ /** 念のための自動失効。本物が追いついたことを検出できなくても、これ以上は残さない */
48
+ const PENDING_OPS_TTL_MS = 24 * 60 * 60 * 1000;
49
+ /**
50
+ * 「保存より後に始まったビルドか」を判断するときの余裕。
51
+ * **トゥームストーン(TOMBSTONE_MARGIN_MS)・アイコンの仮置きと同じ 30 分**——
52
+ * 同じ性質の待ちなので、破棄の条件を別に持つと片方だけ直したときに挙動が割れる。
53
+ */
54
+ const PENDING_OPS_MARGIN_MS = 30 * 60 * 1000;
55
+ /** これだけ経っても live で確かめられない add の行には「反映を確認できていません」を出す */
56
+ const PENDING_OPS_UNCONFIRMED_MS = 30 * 60 * 1000;
57
+ /**
58
+ * live(op=state の registeredNow)の同意で仮置きを捨ててよくなるまでの余裕。
59
+ *
60
+ * 🔴 **保存直後の同意で捨ててはいけない。** op=state の据え置き(60 秒)は Lambda
61
+ * インスタンス単位のメモリで、保存を受けたインスタンスしか消せない。保存直後の取り直しが
62
+ * 同意しても、60 秒以内の再読み込み・取り直しが別インスタンスに当たると保存前の答えが返り、
63
+ * 仮置きを捨てていれば**消したはずの行が復活する**(Issue の症状そのもの・設計レビュー②の重大1)。
64
+ * 据え置き 60 秒に対して余裕を取り、保存から 5 分は同意しても残す(残っていても害は無い——
65
+ * remove は無い行を消すだけ・add は本物があれば足さない)。
66
+ */
67
+ const PENDING_OPS_SETTLE_MS = 5 * 60 * 1000;
68
+ exports.PENDING_OPS_LIMITS = {
69
+ ttlMs: PENDING_OPS_TTL_MS,
70
+ marginMs: PENDING_OPS_MARGIN_MS,
71
+ unconfirmedMs: PENDING_OPS_UNCONFIRMED_MS,
72
+ settleMs: PENDING_OPS_SETTLE_MS,
73
+ };
74
+ /**
75
+ * 仮置きの種別(intake の plan.kind と同じ語)→ 画面の能力名・アダプタ・registeredNow のキー。
76
+ *
77
+ * `cap` / `adapter` はタイルの「確認中」の鍵(pendingCaps と同じ形)、`reg` は
78
+ * op=state の registeredNow のキー(live が追いついたかの判定に使う)。
79
+ * `internal` は同期ソース(internalSources)の kind(Figma / Drive だけ)。
80
+ */
81
+ const PENDING_KINDS = {
82
+ slack: { cap: "チャット", adapter: "slack", reg: "chat" },
83
+ "google-chat": { cap: "チャット", adapter: "google-chat", reg: "google-chat" },
84
+ github: { cap: "開発", adapter: "github", reg: "dev" },
85
+ figma: { cap: "デザイン", adapter: "figma", reg: "design", internal: "デザイン" },
86
+ drive: { cap: "共有資料", adapter: "google-drive", reg: "materials", internal: "共有資料" },
87
+ backlog: { cap: "課題管理", adapter: "backlog", reg: "issues" },
88
+ };
89
+ /** 対応表の読み出し(定数そのものは export しない・上の注意) */
90
+ function pendingKindsTable() { return PENDING_KINDS; }
91
+ /** localStorage の鍵。案件別(同一ブラウザで複数案件を見ても混ざらない・アイコンと同じ切り方) */
92
+ function pendingOpsKey(projectKey) {
93
+ return projectKey ? "cortex-pending-ops:" + String(projectKey) : "";
94
+ }
95
+ // ---- URL の切り出し(サーバ logic.mjs の写し。ずれは parity テストが固定する) --------
96
+ //
97
+ // op=config の応答には正規化済みの ID が載らないので、画面側で切り出す。
98
+ // **切り出せなければ仮置きしない**(従来どおり live の読み直しに任せる・安全側)。
99
+ function pendingSlackChannelId(input) {
100
+ const s = String(input == null ? "" : input).trim();
101
+ if (/^[CG][A-Z0-9]{6,}$/.test(s))
102
+ return s;
103
+ const m = s.match(/\/archives\/([CG][A-Z0-9]{6,})/);
104
+ return m ? m[1] : null;
105
+ }
106
+ function pendingGoogleChatSpaceRef(input) {
107
+ const s = String(input == null ? "" : input).trim();
108
+ if (!s || s.length > 500)
109
+ return null;
110
+ if (!/^https?:\/\/(?:chat|mail)\.google\.com\//i.test(s))
111
+ return null;
112
+ if (/^https?:\/\/chat\.google\.com\/(?:u\/\d+\/)?dm\//i.test(s) ||
113
+ /^https?:\/\/mail\.google\.com\/[^#]*#chat\/dm\//i.test(s)) {
114
+ return null;
115
+ }
116
+ const m = s.match(/^https?:\/\/chat\.google\.com\/(?:u\/\d+\/)?room\/([^/?#]+)/i) ||
117
+ s.match(/^https?:\/\/mail\.google\.com\/(?:chat|mail)\/u\/\d+\/[^#]*#chat\/space\/([^/?#]+)/i);
118
+ if (!m)
119
+ return null;
120
+ const ref = "spaces/" + m[1];
121
+ return /^spaces\/[A-Za-z0-9_-]+$/.test(ref) ? ref : null;
122
+ }
123
+ function pendingRepo(input) {
124
+ let s = String(input == null ? "" : input).trim();
125
+ if (!s)
126
+ return null;
127
+ s = s.replace(/^git@github\.com:/, "").replace(/^https?:\/\/github\.com\//, "");
128
+ s = s.replace(/\.git$/, "").replace(/^\/+/, "");
129
+ const parts = s.split("/").filter(function (p) { return !!p; });
130
+ if (parts.length < 2)
131
+ return null;
132
+ const repo = (parts[0] + "/" + parts[1]).toLowerCase();
133
+ return /^[a-z0-9._-]+\/[a-z0-9._-]+$/.test(repo) ? repo : null;
134
+ }
135
+ function pendingFigmaKey(input) {
136
+ const s = String(input == null ? "" : input).trim();
137
+ const m = s.match(/figma\.com\/(?:design|file|proto)\/([A-Za-z0-9_-]{8,})/);
138
+ if (m)
139
+ return m[1];
140
+ return /^[A-Za-z0-9_-]{8,}$/.test(s) ? s : null;
141
+ }
142
+ function pendingDriveFolderId(input) {
143
+ const s = String(input == null ? "" : input).trim();
144
+ const m = s.match(/\/drive\/(?:u\/\d+\/)?folders\/([A-Za-z0-9_-]+)/) ||
145
+ s.match(/[?&]id=([A-Za-z0-9_-]+)/);
146
+ if (m)
147
+ return m[1];
148
+ return /^[A-Za-z0-9_-]{10,}$/.test(s) ? s : null;
149
+ }
150
+ /**
151
+ * Backlog の URL → 接続先(ドメインとプロジェクトキー)。サーバ(logic.mjs の backlogTarget)と同じ形。
152
+ *
153
+ * もともと render.ts にあった写し(画面の接続先の復元・保存の突き合わせに使う)を、
154
+ * 仮置きも同じものを使うようにここへ移した。**写しは 1 つだけ**(parity テストが見張る)。
155
+ */
156
+ function backlogTargetOf(input) {
157
+ const s = String(input == null ? "" : input).trim();
158
+ const m = s.match(/^https?:\/\/([a-z0-9-]+\.(?:backlog\.jp|backlog\.com|backlogtool\.com))\/(.*)$/i);
159
+ if (!m)
160
+ return null;
161
+ const domain = m[1].toLowerCase();
162
+ const rest = m[2];
163
+ // **数値のプロジェクトIDは弾く。** 取り違えの検査(艦隊のキーとの照合)を
164
+ // 迂回できてしまう。Backlog はドキュメントのURLをこの形でも出す
165
+ const num = /^[0-9]+$/;
166
+ const head = rest.match(/^(?:projects|document|wiki|find|board|gantt|git)\/([A-Za-z0-9_]+)(?:[/?#]|$)/);
167
+ if (head)
168
+ return num.test(head[1]) ? null : { domain: domain, projectKey: head[1].toUpperCase() };
169
+ const view = rest.match(/^view\/([A-Za-z0-9_]+)-\d+/);
170
+ if (view)
171
+ return num.test(view[1]) ? null : { domain: domain, projectKey: view[1].toUpperCase() };
172
+ return null;
173
+ }
174
+ /** Backlog の接続先の鍵(render.ts の issuesRefOf と同形: ドメイン小文字 / キー大文字) */
175
+ function pendingIssuesRef(domain, projectKey) {
176
+ return String(domain == null ? "" : domain).toLowerCase() + "/" + String(projectKey == null ? "" : projectKey).toUpperCase();
177
+ }
178
+ // ---- エントリを作る(op → kind / action / targetId の対応表はここ 1 つ) ------------
179
+ /** 表示名の整形(サーバの cleanLabel と同じ考え方: 制御文字を落とし、長すぎれば切る) */
180
+ function pendingLabel(s, max) {
181
+ const v = String(s == null ? "" : s).replace(/[\u0000-\u001f\u007f]/g, " ").trim();
182
+ const lim = max || 120;
183
+ return v.length > lim ? v.slice(0, lim) : v;
184
+ }
185
+ /**
186
+ * `op=config` に送った payload(と、必要なら応答)→ 仮置きのエントリ。作れなければ null。
187
+ *
188
+ * @param payload 送った中身(`payload.op` を見る)。refresh-channel-name は送っていない
189
+ * `name`(引けた名前)を呼び出し側が添える
190
+ * @param ctx { projectKey, now, generatedAt, targets? }
191
+ * targets は Backlog の解除で表示中の一覧(ドメインを補うため・無くてもよい)
192
+ *
193
+ * **応答に正規化済みの ref / name が載るようになったら、ここで `ctx.response` を先に読む**
194
+ * (intake 側 3 行の追加で Google Chat の名前も即時に出せる。今回は intake を触らない)。
195
+ */
196
+ function pendingOpOf(payload, ctx) {
197
+ if (!payload || typeof payload !== "object" || !ctx || !ctx.projectKey)
198
+ return null;
199
+ const op = String(payload.op || "");
200
+ const make = (kind, action, targetId, data) => {
201
+ const id = targetId == null ? "" : String(targetId);
202
+ if (!id)
203
+ return null;
204
+ return {
205
+ v: 1,
206
+ projectKey: String(ctx.projectKey),
207
+ kind: kind,
208
+ targetId: id,
209
+ action: action,
210
+ payload: data || {},
211
+ committedAt: typeof ctx.now === "number" ? ctx.now : Date.now(),
212
+ baseBuildId: typeof ctx.generatedAt === "string" ? ctx.generatedAt : "",
213
+ };
214
+ };
215
+ switch (op) {
216
+ case "add-channel": {
217
+ const id = pendingSlackChannelId(payload.url);
218
+ const data = { name: pendingLabel(payload.name) || id };
219
+ if (payload.workspace)
220
+ data.workspace = String(payload.workspace);
221
+ return make("slack", "add", id, data);
222
+ }
223
+ case "remove-channel":
224
+ return make("slack", "remove", payload.ref);
225
+ case "refresh-channel-name": {
226
+ const name = pendingLabel(payload.name);
227
+ return name ? make("slack", "update", payload.ref, { name: name }) : null;
228
+ }
229
+ case "set-notify":
230
+ return typeof payload.value === "boolean" ? make("slack", "update", payload.ref, { notify: payload.value }) : null;
231
+ case "add-google-chat-space": {
232
+ const ref = pendingGoogleChatSpaceRef(payload.url);
233
+ // 名前は画面に欄が無い(サーバが引く)。live が来るまでは ref で出す
234
+ return make("google-chat", "add", ref, { name: ref || "" });
235
+ }
236
+ case "remove-google-chat-space":
237
+ return make("google-chat", "remove", payload.ref);
238
+ case "add-github-source": {
239
+ const repo = pendingRepo(payload.repo);
240
+ return make("github", "add", repo, { name: repo || "", type: "github-issues" });
241
+ }
242
+ case "remove-github-source":
243
+ return make("github", "remove", pendingRepo(payload.repo));
244
+ case "add-figma-file": {
245
+ const key = pendingFigmaKey(payload.url);
246
+ return make("figma", "add", key, { name: pendingLabel(payload.name) || key || "" });
247
+ }
248
+ case "remove-figma-file":
249
+ return make("figma", "remove", pendingFigmaKey(payload.url) || payload.key);
250
+ case "add-drive-folder": {
251
+ const id = pendingDriveFolderId(payload.url);
252
+ return make("drive", "add", id, { name: pendingLabel(payload.name) });
253
+ }
254
+ case "remove-drive-folder":
255
+ return make("drive", "remove", pendingDriveFolderId(payload.url) || payload.id);
256
+ case "backlog-target-add": {
257
+ const t = backlogTargetOf(payload.url);
258
+ return t ? make("backlog", "add", pendingIssuesRef(t.domain, t.projectKey), { domain: t.domain, projectKey: t.projectKey }) : null;
259
+ }
260
+ case "backlog-target-remove": {
261
+ // **鍵はドメイン込み(issuesRefOf と同形)。** projectKey だけで丸めると、2 つのドメインに
262
+ // 同じキーがある案件で片方の解除が両方を消し、live の同意も成立せず 24 時間消えたままになる。
263
+ // 送る payload にはドメインが無いので、表示中の一覧(ctx.targets)から引く。引けなければ仮置きしない
264
+ const key = String(payload.backlogProjectKey || "").trim().toUpperCase();
265
+ if (!/^[A-Z0-9_]{1,64}$/.test(key))
266
+ return null;
267
+ let domain = "";
268
+ for (const t of (ctx.targets || [])) {
269
+ if (t && String(t.projectKey || "").toUpperCase() === key && t.domain) {
270
+ domain = String(t.domain);
271
+ break;
272
+ }
273
+ }
274
+ return domain ? make("backlog", "remove", pendingIssuesRef(domain, key)) : null;
275
+ }
276
+ default:
277
+ return null;
278
+ }
279
+ }
280
+ // ---- localStorage の生値の検査 -------------------------------------------------
281
+ /**
282
+ * localStorage の生値 → 通ってよいエントリだけ。**通らなかったものは黙って落とす。**
283
+ * 形の版(v)と案件キーはアイコンの仮置きと同じ検査。
284
+ */
285
+ function sanitizePendingOps(raw, projectKey) {
286
+ const out = [];
287
+ if (!Array.isArray(raw) || !projectKey)
288
+ return out;
289
+ for (const e of raw) {
290
+ if (!e || typeof e !== "object")
291
+ continue;
292
+ if (e.v !== 1 || e.projectKey !== String(projectKey))
293
+ continue;
294
+ if (!PENDING_KINDS[e.kind])
295
+ continue;
296
+ if (e.action !== "add" && e.action !== "remove" && e.action !== "update")
297
+ continue;
298
+ if (typeof e.targetId !== "string" || !e.targetId)
299
+ continue;
300
+ if (typeof e.committedAt !== "number" || !isFinite(e.committedAt))
301
+ continue;
302
+ const payload = {};
303
+ const src = e.payload && typeof e.payload === "object" ? e.payload : {};
304
+ // 通してよい payload の欄(葉は文字列か、notify だけ真偽値)。関数の中に置くのは、
305
+ // クライアントへ toString() で流し込んだ先でも同じ一覧を参照できるようにするため
306
+ const fields = ["name", "workspace", "notify", "domain", "projectKey", "type"];
307
+ for (const f of fields) {
308
+ if (!Object.prototype.hasOwnProperty.call(src, f))
309
+ continue;
310
+ if (f === "notify") {
311
+ if (src[f] === true || src[f] === false)
312
+ payload[f] = src[f];
313
+ }
314
+ else if (typeof src[f] === "string")
315
+ payload[f] = src[f];
316
+ }
317
+ out.push({
318
+ v: 1,
319
+ projectKey: String(projectKey),
320
+ kind: e.kind,
321
+ targetId: e.targetId,
322
+ action: e.action,
323
+ payload: payload,
324
+ committedAt: e.committedAt,
325
+ baseBuildId: typeof e.baseBuildId === "string" ? e.baseBuildId : "",
326
+ });
327
+ }
328
+ out.sort(function (a, b) { return a.committedAt - b.committedAt; });
329
+ return out;
330
+ }
331
+ // ---- 後勝ちの合成 ----------------------------------------------------------
332
+ /**
333
+ * 同じ (kind, targetId) は後勝ち。
334
+ * - add / remove は既存を全部捨てて置き換える(add → remove で行は消え、remove → add で戻る)
335
+ * - update は既存の add があれば**その payload に畳む**(add の行を残す)。無ければ既存の
336
+ * update と payload を合成して置き換える(name と notify は別の操作で入るので、欄をまたいで捨てない)
337
+ */
338
+ function rememberPendingOp(list, entry) {
339
+ const cur = list || [];
340
+ if (!entry)
341
+ return cur.slice();
342
+ const same = function (e) { return e.kind === entry.kind && e.targetId === entry.targetId; };
343
+ if (entry.action === "update") {
344
+ let folded = false;
345
+ const out = cur.map(function (e) {
346
+ if (!same(e) || e.action !== "add")
347
+ return e;
348
+ folded = true;
349
+ const merged = {};
350
+ for (const k in e.payload)
351
+ if (Object.prototype.hasOwnProperty.call(e.payload, k))
352
+ merged[k] = e.payload[k];
353
+ for (const k in entry.payload)
354
+ if (Object.prototype.hasOwnProperty.call(entry.payload, k))
355
+ merged[k] = entry.payload[k];
356
+ return { v: 1, projectKey: e.projectKey, kind: e.kind, targetId: e.targetId, action: e.action, payload: merged, committedAt: e.committedAt, baseBuildId: e.baseBuildId };
357
+ });
358
+ if (folded)
359
+ return out;
360
+ // 既存の update があれば **payload を合成して** 置き換える(add に畳むときと同じ)。
361
+ // 欄ごとに捨てると、通知先 ON → 名前の取り直し の順に押したとき notify の仮置きが消え、
362
+ // 据え置きの窓で再読み込みすると通知先が OFF に見える(実装レビュー④)
363
+ const merged = {};
364
+ for (const e of cur) {
365
+ if (!same(e) || e.action !== "update")
366
+ continue;
367
+ for (const k in e.payload)
368
+ if (Object.prototype.hasOwnProperty.call(e.payload, k))
369
+ merged[k] = e.payload[k];
370
+ }
371
+ for (const k in entry.payload)
372
+ if (Object.prototype.hasOwnProperty.call(entry.payload, k))
373
+ merged[k] = entry.payload[k];
374
+ const rest = cur.filter(function (e) { return !(same(e) && e.action === "update"); });
375
+ rest.push({ v: 1, projectKey: entry.projectKey, kind: entry.kind, targetId: entry.targetId, action: entry.action, payload: merged, committedAt: entry.committedAt, baseBuildId: entry.baseBuildId });
376
+ return rest;
377
+ }
378
+ const kept = cur.filter(function (e) { return !same(e); });
379
+ kept.push(entry);
380
+ return kept;
381
+ }
382
+ // ---- 失効 ----------------------------------------------------------------
383
+ /** SITE.generatedAt("2026-09-03 00:00 UTC" / ISO)→ ms。読めなければ NaN */
384
+ function pendingSiteTimeMs(s) {
385
+ if (typeof s !== "string" || !s)
386
+ return NaN;
387
+ return Date.parse(s.replace(" UTC", "Z").replace(" ", "T"));
388
+ }
389
+ /**
390
+ * 本物の一覧の世代が追いついたか。**判定の形はアイコン(iconPendingStale)・トゥームストーンと同じ**
391
+ * ——いま画面が持っている世代が、保存時刻+余裕 と 保存時に見ていた世代+余裕 の両方より新しい。
392
+ *
393
+ * 🔴 **世代は「一覧の下地」のもの(fleet-status の generatedAt)を渡すこと。** ページの
394
+ * generatedAt(SITE.generatedAt)は記録の追加でも進むが、一覧の下地は夜間の自己チェックの
395
+ * 成果物なので、ページが新しくても下地は古いままのことがある(設計レビュー②の中3)。
396
+ * 生成時刻はコミット時点ではない(保存前に始まった実行が後から完成する)ので余裕を取る。
397
+ */
398
+ function pendingOpBuildCaughtUp(e, siteNowMs) {
399
+ if (!e || typeof siteNowMs !== "number" || isNaN(siteNowMs))
400
+ return false;
401
+ const byAt = siteNowMs > e.committedAt + PENDING_OPS_MARGIN_MS;
402
+ const gen0 = pendingSiteTimeMs(e.baseBuildId);
403
+ if (isNaN(gen0))
404
+ return byAt;
405
+ return byAt && siteNowMs > gen0 + PENDING_OPS_MARGIN_MS;
406
+ }
407
+ /** registeredNow の行が、このエントリの対象か */
408
+ function pendingLiveRowMatches(kind, r, targetId) {
409
+ if (!r)
410
+ return false;
411
+ if (kind === "figma")
412
+ return String(r.key) === targetId;
413
+ if (kind === "drive")
414
+ return String(r.id) === targetId;
415
+ if (kind === "backlog")
416
+ return pendingIssuesRef(r.domain, r.projectKey) === targetId;
417
+ return String(r.ref) === targetId;
418
+ }
419
+ /**
420
+ * live(op=state の registeredNow)が仮置きと同じことを言っているか。
421
+ *
422
+ * **保存から 5 分(PENDING_OPS_SETTLE_MS)を過ぎてから出した要求の答えだけを信じる。**
423
+ * 据え置きの古い答えが仮置きと食い違っていても残す——その局面こそ仮置きが要る場面。
424
+ * 5 分以内の同意で捨てないのは、別インスタンスの据え置きが保存前の答えを返す窓があるため
425
+ * (PENDING_OPS_SETTLE_MS の項)。同意していれば本物の行が出ているので、残っていても害は無い。
426
+ * その kind のキーが配列で無ければ判断しない(読めなかった=分からない)。
427
+ */
428
+ function pendingOpLiveAgrees(e, live, liveAt) {
429
+ if (!e || !live || typeof live !== "object")
430
+ return false;
431
+ // 保存から据え置きの余裕(PENDING_OPS_SETTLE_MS)を過ぎてから出した要求の答えだけを信じる
432
+ if (typeof liveAt !== "number" || !(liveAt >= e.committedAt + PENDING_OPS_SETTLE_MS))
433
+ return false;
434
+ const meta = PENDING_KINDS[e.kind];
435
+ if (!meta)
436
+ return false;
437
+ const rows = live[meta.reg];
438
+ if (!Array.isArray(rows))
439
+ return false;
440
+ let found = null;
441
+ for (const r of rows)
442
+ if (pendingLiveRowMatches(e.kind, r, e.targetId)) {
443
+ found = r;
444
+ break;
445
+ }
446
+ if (e.action === "remove")
447
+ return !found;
448
+ if (e.action === "add")
449
+ return !!found;
450
+ if (!found)
451
+ return false;
452
+ const p = e.payload || {};
453
+ // 名前は緩く比べる(サーバは cleanLabel 済み・画面は先頭の # を剥いで出す)。厳密一致だと
454
+ // 表記の差だけで 24 時間残る
455
+ const loose = function (s) { return String(s == null ? "" : s).trim().replace(/^#+/, ""); };
456
+ if (typeof p.name === "string" && loose(found.name) !== loose(p.name))
457
+ return false;
458
+ if (typeof p.notify === "boolean" && (found.notify === true) !== p.notify)
459
+ return false;
460
+ return true;
461
+ }
462
+ /**
463
+ * タイルの「登録件数」に仮置きを加減する(設計レビュー②の重大2)。
464
+ *
465
+ * タイルと接続マップの語は registeredNow の**件数**から導く(registeredCountOf → deriveCapTile)ので、
466
+ * 一覧だけ仮置きで足し引きしても「行は消えたのにタイルは登録済み 1 件」「行は 1 件なのに未接続」が並ぶ。
467
+ * live の行に無い add は +1、live の行に在る remove は −1(本物が既に追いついていれば 0)。
468
+ *
469
+ * @param live registeredNow(無ければ 0)
470
+ * @param regKey registeredNow のキー(chat / google-chat / dev / design / materials / issues)
471
+ */
472
+ function pendingCountDelta(list, live, regKey) {
473
+ const rows = live && typeof live === "object" && Array.isArray(live[regKey]) ? live[regKey] : null;
474
+ if (!rows)
475
+ return 0;
476
+ let delta = 0;
477
+ for (const e of list || []) {
478
+ const meta = e && PENDING_KINDS[e.kind];
479
+ if (!meta || meta.reg !== regKey)
480
+ continue;
481
+ let found = false;
482
+ for (const r of rows)
483
+ if (pendingLiveRowMatches(e.kind, r, e.targetId)) {
484
+ found = true;
485
+ break;
486
+ }
487
+ if (e.action === "add" && !found)
488
+ delta += 1;
489
+ if (e.action === "remove" && found)
490
+ delta -= 1;
491
+ }
492
+ return delta;
493
+ }
494
+ /**
495
+ * 失効したエントリを落とす。
496
+ *
497
+ * @param ctx { now: Date.now(), siteNowMs: 画面の generatedAt の ms, live: LIVE_REGISTERED, liveAt: live が届いた時刻 }
498
+ */
499
+ function prunePendingOps(list, ctx) {
500
+ const c = ctx || {};
501
+ const now = typeof c.now === "number" ? c.now : Date.now();
502
+ return (list || []).filter(function (e) {
503
+ if (!e)
504
+ return false;
505
+ if (now > e.committedAt + PENDING_OPS_TTL_MS)
506
+ return false;
507
+ if (pendingOpBuildCaughtUp(e, c.siteNowMs))
508
+ return false;
509
+ if (pendingOpLiveAgrees(e, c.live, c.liveAt))
510
+ return false;
511
+ return true;
512
+ });
513
+ }
514
+ // ---- 合成 ----------------------------------------------------------------
515
+ /** 外部ソース(externalSources)の行が、このエントリの対象か。Slack は ref のみ(subtractRemovedRegistered と同じ理由) */
516
+ function pendingExternalMatches(kind, s, targetId) {
517
+ if (!s || !s.ref || String(s.ref) !== targetId)
518
+ return false;
519
+ if (kind === "slack")
520
+ return s.type === "slack";
521
+ if (kind === "google-chat")
522
+ return s.type === "google-chat";
523
+ if (kind === "github")
524
+ return String(s.type || "").indexOf("github") === 0;
525
+ return false;
526
+ }
527
+ /** 仮置きの add で足す行(registeredRowsToAdd と同じ形+ pending の印) */
528
+ function pendingExternalRow(e) {
529
+ const p = e.payload || {};
530
+ const row = {};
531
+ if (e.kind === "slack") {
532
+ row.type = "slack";
533
+ row.name = p.name || e.targetId;
534
+ row.ref = e.targetId;
535
+ // channels.json に書いた行= derived(dev-sources 側の宣言ではない)
536
+ row.origin = "derived";
537
+ if (p.workspace)
538
+ row.workspace = p.workspace;
539
+ if (p.notify === true)
540
+ row.notify = true;
541
+ }
542
+ else if (e.kind === "google-chat") {
543
+ row.type = "google-chat";
544
+ row.name = p.name || e.targetId;
545
+ row.ref = e.targetId;
546
+ }
547
+ else {
548
+ row.type = p.type || "github-issues";
549
+ row.name = e.targetId;
550
+ row.ref = e.targetId;
551
+ row.origin = "explicit";
552
+ }
553
+ row.pending = true;
554
+ row.pendingAt = e.committedAt;
555
+ return row;
556
+ }
557
+ function pendingInternalOf(list, kind) {
558
+ for (const s of list || [])
559
+ if (s && s.kind === kind)
560
+ return s;
561
+ return null;
562
+ }
563
+ /**
564
+ * 本物の設定(4層合成の結果)に仮置きを重ねる。**描画の手前のここ 1 箇所で合成する**
565
+ * (一覧・タイル・接続マップの注記は全部 fleetVal を通るので、描画側に散らすと食い違う)。
566
+ *
567
+ * - remove: 行を消す。Drive は 0 件になったら `on → empty`、Backlog は単数欄を先頭に揃える
568
+ * (subtractRemovedRegistered と同じ倒し方)
569
+ * - add: 同じ鍵の行が**既にあれば何もしない**(本物が先に来ている)。無ければ
570
+ * registeredRowsToAdd と同じ形の行を足し、`pending: true` / `pendingAt` を付ける
571
+ * (Drive は行が ID 文字列なので `drivePending: { id: committedAt }` を内部ソースに付ける)
572
+ * - update: Slack の `name` / `notify` だけ差し替える
573
+ */
574
+ function applyPendingOps(view, list) {
575
+ const v = view || {};
576
+ const ops = list || [];
577
+ if (!ops.length)
578
+ return v;
579
+ let externals = (v.externalSources || []).slice();
580
+ let internals = (v.internalSources || []).slice();
581
+ let bt = v.backlogTarget;
582
+ let extChanged = false;
583
+ let intChanged = false;
584
+ let btChanged = false;
585
+ const putInternal = function (kind, patch) {
586
+ for (let i = 0; i < internals.length; i++) {
587
+ if (internals[i] && internals[i].kind === kind) {
588
+ internals[i] = Object.assign({}, internals[i], patch);
589
+ return;
590
+ }
591
+ }
592
+ internals.push(Object.assign({ kind: kind }, patch));
593
+ };
594
+ for (const e of ops) {
595
+ if (!e || !PENDING_KINDS[e.kind])
596
+ continue;
597
+ const p = e.payload || {};
598
+ if (e.kind === "slack" || e.kind === "google-chat" || e.kind === "github") {
599
+ const idx = externals.findIndex(function (s) { return pendingExternalMatches(e.kind, s, e.targetId); });
600
+ if (e.action === "remove") {
601
+ // **Slack は origin が "derived" の行だけ消す**(subtractRemovedRegistered と同じ守り)。
602
+ // dev-sources.json 側にも宣言された行(both / explicit)は channels.json から消しても
603
+ // エンジンがまだ読む——消すと「読まれているのに画面に無い」逆向きの嘘になり、
604
+ // live が追いついた後に復活する(設計レビュー②の中5)。そういう行は従来どおり取り消し線のまま
605
+ const removable = function (s) {
606
+ return pendingExternalMatches(e.kind, s, e.targetId) && (e.kind !== "slack" || s.origin === "derived");
607
+ };
608
+ if (!externals.some(removable))
609
+ continue;
610
+ externals = externals.filter(function (s) { return !removable(s); });
611
+ extChanged = true;
612
+ }
613
+ else if (e.action === "add") {
614
+ if (idx >= 0)
615
+ continue;
616
+ externals.push(pendingExternalRow(e));
617
+ extChanged = true;
618
+ }
619
+ else if (e.kind === "slack" && idx >= 0) {
620
+ const patch = {};
621
+ if (typeof p.name === "string" && p.name)
622
+ patch.name = p.name;
623
+ if (typeof p.notify === "boolean")
624
+ patch.notify = p.notify;
625
+ externals[idx] = Object.assign({}, externals[idx], patch);
626
+ extChanged = true;
627
+ }
628
+ }
629
+ else if (e.kind === "figma") {
630
+ const cur = pendingInternalOf(internals, "デザイン");
631
+ const files = (cur && cur.figmaFiles) || [];
632
+ const has = files.some(function (f) { return f && String(f.key) === e.targetId; });
633
+ if (e.action === "remove") {
634
+ if (!has)
635
+ continue;
636
+ putInternal("デザイン", { figmaFiles: files.filter(function (f) { return !f || String(f.key) !== e.targetId; }) });
637
+ intChanged = true;
638
+ }
639
+ else if (e.action === "add") {
640
+ if (has)
641
+ continue;
642
+ putInternal("デザイン", { figmaFiles: files.concat([{ key: e.targetId, name: p.name || e.targetId, pending: true, pendingAt: e.committedAt }]) });
643
+ intChanged = true;
644
+ }
645
+ }
646
+ else if (e.kind === "drive") {
647
+ const cur = pendingInternalOf(internals, "共有資料");
648
+ const ids = (cur && cur.driveFolderIds) || [];
649
+ const has = ids.indexOf(e.targetId) >= 0;
650
+ if (e.action === "remove") {
651
+ if (!has)
652
+ continue;
653
+ const kept = ids.filter(function (id) { return id !== e.targetId; });
654
+ const names = {};
655
+ for (const id of kept)
656
+ if (cur.driveFolderNames && cur.driveFolderNames[id])
657
+ names[id] = cur.driveFolderNames[id];
658
+ const patch = { driveFolderIds: kept, driveFolderNames: names };
659
+ if (cur.drivePending && cur.drivePending[e.targetId] != null) {
660
+ const pend = Object.assign({}, cur.drivePending);
661
+ delete pend[e.targetId];
662
+ patch.drivePending = pend;
663
+ }
664
+ // 0 件になったら empty に倒す(on のときだけ。broken / off は別の答えなので潰さない)
665
+ if (!kept.length && cur.driveState === "on")
666
+ patch.driveState = "empty";
667
+ putInternal("共有資料", patch);
668
+ intChanged = true;
669
+ }
670
+ else if (e.action === "add") {
671
+ if (has)
672
+ continue;
673
+ const names = Object.assign({}, (cur && cur.driveFolderNames) || {});
674
+ if (p.name)
675
+ names[e.targetId] = p.name;
676
+ const pend = Object.assign({}, (cur && cur.drivePending) || {});
677
+ pend[e.targetId] = e.committedAt;
678
+ const patch = { driveFolderIds: ids.concat([e.targetId]), driveFolderNames: names, drivePending: pend };
679
+ // 一覧そのものが driveState 無しでは描かれない。無い/0 件(empty)なら on にする
680
+ if (!cur || !cur.driveState || cur.driveState === "empty")
681
+ patch.driveState = "on";
682
+ putInternal("共有資料", patch);
683
+ intChanged = true;
684
+ }
685
+ }
686
+ else if (e.kind === "backlog") {
687
+ const cur = bt && Array.isArray(bt.targets) ? bt.targets : bt && bt.domain && bt.projectKey ? [bt] : [];
688
+ // 突き合わせはドメイン込み(同じキーが 2 つのドメインにある案件で片方だけを消す)
689
+ const has = cur.some(function (t) { return t && pendingIssuesRef(t.domain, t.projectKey) === e.targetId; });
690
+ let next = null;
691
+ if (e.action === "remove" && has) {
692
+ next = cur.filter(function (t) { return !t || pendingIssuesRef(t.domain, t.projectKey) !== e.targetId; });
693
+ }
694
+ else if (e.action === "add" && !has && p.domain && p.projectKey) {
695
+ next = cur.concat([{ domain: p.domain, projectKey: p.projectKey, pending: true, pendingAt: e.committedAt }]);
696
+ }
697
+ if (!next)
698
+ continue;
699
+ const patch = Object.assign({}, bt || {}, { targets: next });
700
+ if (next.length) {
701
+ patch.domain = next[0].domain;
702
+ patch.projectKey = next[0].projectKey;
703
+ }
704
+ else {
705
+ delete patch.domain;
706
+ delete patch.projectKey;
707
+ }
708
+ bt = patch;
709
+ btChanged = true;
710
+ }
711
+ }
712
+ if (!extChanged && !intChanged && !btChanged)
713
+ return v;
714
+ const out = Object.assign({}, v);
715
+ if (extChanged)
716
+ out.externalSources = externals;
717
+ if (intChanged)
718
+ out.internalSources = internals;
719
+ if (btChanged)
720
+ out.backlogTarget = bt;
721
+ return out;
722
+ }
723
+ // ---- タイルと行の語 --------------------------------------------------------
724
+ /**
725
+ * 仮置きを持つ能力/アダプタの鍵(pendingCaps と同じ形: `能力` と `能力\tアダプタ`)。
726
+ * タイルは「登録状況を確認中です」に倒す——仮置きの add で 0 → 1 件になった能力に
727
+ * 「未接続」と言わせないため(語は増やさない)。
728
+ */
729
+ function pendingCapsOf(list) {
730
+ const out = {};
731
+ for (const e of list || []) {
732
+ const meta = e && PENDING_KINDS[e.kind];
733
+ if (!meta)
734
+ continue;
735
+ out[meta.cap] = 1;
736
+ out[meta.cap + "\t" + meta.adapter] = 1;
737
+ }
738
+ return out;
739
+ }
740
+ /**
741
+ * 仮置きの行に出す語。印の無い行は空文字。
742
+ * 「反映待ち」/ 30 分経っても live で確かめられなければ「反映を確認できていません」。
743
+ */
744
+ function pendingRowNote(row, nowMs) {
745
+ if (!row || row.pending !== true)
746
+ return "";
747
+ if (typeof row.pendingAt === "number" && typeof nowMs === "number" && nowMs > row.pendingAt + PENDING_OPS_UNCONFIRMED_MS) {
748
+ return "反映を確認できていません";
749
+ }
750
+ return "反映待ち";
751
+ }
package/dist/render.js CHANGED
@@ -32,6 +32,8 @@ exports.deriveCapTile = deriveCapTile;
32
32
  exports.renderConnectionMap = renderConnectionMap;
33
33
  const node_crypto_1 = require("node:crypto");
34
34
  const meet_guide_images_1 = require("./meet-guide-images");
35
+ // 操作タブの仮置き(#388)。判定の実体はあちら——ここは流し込みと配線だけ
36
+ const pending_ops_1 = require("./pending-ops");
35
37
  /**
36
38
  * SiteDataをHTMLにレンダリングする。
37
39
  *
@@ -477,7 +479,8 @@ function capabilityDisplayState(cap, caps) {
477
479
  return s === "empty" ? "unset" : s;
478
480
  }
479
481
  /**
480
- * 貼られたURLから Backlog の接続先を読む。
482
+ * 貼られたURLから Backlog の接続先を読む——実体は pending-ops.ts の backlogTargetOf
483
+ *(仮置きも同じ切り出しを使うので、写しを 1 つにするためあちらへ移した・#388)。
481
484
  *
482
485
  * **クライアントへは .toString() で渡す。** テンプレート文字列の中に正規表現を
483
486
  * 手で書くと、バックスラッシュが二重に潰れて別物になる(実際に
@@ -486,26 +489,8 @@ function capabilityDisplayState(cap, caps) {
486
489
  *
487
490
  * **判定の正本は Lambda 側**(そちらが書き込む)。ここは確認文の表示用で、
488
491
  * 食い違うと「画面はこう見せたのに別のものが書かれた」になるので、
489
- * 同じ形かをテストで突き合わせている。
492
+ * 同じ形かをテストで突き合わせている(pending-ops-parity.test.mjs)。
490
493
  */
491
- function backlogTargetOf(input) {
492
- const s = String(input == null ? "" : input).trim();
493
- const m = s.match(/^https?:\/\/([a-z0-9-]+\.(?:backlog\.jp|backlog\.com|backlogtool\.com))\/(.*)$/i);
494
- if (!m)
495
- return null;
496
- const domain = m[1].toLowerCase();
497
- const rest = m[2];
498
- // **数値のプロジェクトIDは弾く。** 取り違えの検査(艦隊のキーとの照合)を
499
- // 迂回できてしまう。Backlog はドキュメントのURLをこの形でも出す
500
- const num = /^[0-9]+$/;
501
- const head = rest.match(/^(?:projects|document|wiki|find|board|gantt|git)\/([A-Za-z0-9_]+)(?:[/?#]|$)/);
502
- if (head)
503
- return num.test(head[1]) ? null : { domain: domain, projectKey: head[1].toUpperCase() };
504
- const view = rest.match(/^view\/([A-Za-z0-9_]+)-\d+/);
505
- if (view)
506
- return num.test(view[1]) ? null : { domain: domain, projectKey: view[1].toUpperCase() };
507
- return null;
508
- }
509
494
  // ---------------------------------------------------------------------------
510
495
  // 画面が「まだ答えが無い」あいだの見切り。
511
496
  //
@@ -2952,6 +2937,8 @@ a.ds-tile:hover { text-decoration: none; }
2952
2937
  /* どの Slack ワークスペースのチャンネルか(#104)。**由来のバッジと同じ強さにする**——
2953
2938
  「Gold 対象か」より弱い、どれのことかを見分けるための情報。強くすると判断のバッジと競る */
2954
2939
  .badge.ds-workspace { background: transparent; color: var(--muted); border: 1px solid var(--border); }
2940
+ /* 仮置きの行の印(#388)。控えめに——本物が追いつけば消える印なので目立たせない */
2941
+ .badge.ds-pending-badge { background: transparent; color: var(--muted); border: 1px dashed var(--border); font-weight: 500; }
2955
2942
  /* 接続済み Slack ワークスペースの一覧(#104)。追加フォームの中に置く */
2956
2943
  .ds-ws-list { display: flex; flex-direction: column; gap: 6px; margin: 8px 0 10px; }
2957
2944
  .ds-ws { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; font-size: 12.5px; }
@@ -3390,7 +3377,7 @@ ${adapterResolved.toString()}
3390
3377
  ${capabilityAdapter.toString()}
3391
3378
  ${capabilityRawState.toString()}
3392
3379
  ${capabilityDisplayState.toString()}
3393
- ${backlogTargetOf.toString()}
3380
+ ${pending_ops_1.backlogTargetOf.toString()}
3394
3381
  ${issuesRefOf.toString()}
3395
3382
  ${sourceNeedsAttention.toString()}
3396
3383
  ${capabilityState.toString()}
@@ -3417,6 +3404,36 @@ ${subtractRemovedRegistered.toString()}
3417
3404
  ${registeredRowsToAdd.toString()}
3418
3405
  ${unsettledRegisteredCount.toString()}
3419
3406
  ${mergeRegisteredFleet.toString()}
3407
+ // 操作タブの仮置き(#388)。**判定の実体は pending-ops.ts**——画面側に書き写すと
3408
+ // 対応表が2枚になる。定数は同名で注入する(流し込んだ関数がこの名前を参照する)
3409
+ var PENDING_OPS_TTL_MS = ${JSON.stringify(pending_ops_1.PENDING_OPS_LIMITS.ttlMs)};
3410
+ var PENDING_OPS_MARGIN_MS = ${JSON.stringify(pending_ops_1.PENDING_OPS_LIMITS.marginMs)};
3411
+ var PENDING_OPS_UNCONFIRMED_MS = ${JSON.stringify(pending_ops_1.PENDING_OPS_LIMITS.unconfirmedMs)};
3412
+ var PENDING_OPS_SETTLE_MS = ${JSON.stringify(pending_ops_1.PENDING_OPS_LIMITS.settleMs)};
3413
+ var PENDING_KINDS = ${JSON.stringify((0, pending_ops_1.pendingKindsTable)())};
3414
+ ${pending_ops_1.pendingOpsKey.toString()}
3415
+ ${pending_ops_1.pendingSlackChannelId.toString()}
3416
+ ${pending_ops_1.pendingGoogleChatSpaceRef.toString()}
3417
+ ${pending_ops_1.pendingRepo.toString()}
3418
+ ${pending_ops_1.pendingFigmaKey.toString()}
3419
+ ${pending_ops_1.pendingDriveFolderId.toString()}
3420
+ ${pending_ops_1.pendingLabel.toString()}
3421
+ ${pending_ops_1.pendingOpOf.toString()}
3422
+ ${pending_ops_1.sanitizePendingOps.toString()}
3423
+ ${pending_ops_1.rememberPendingOp.toString()}
3424
+ ${pending_ops_1.pendingSiteTimeMs.toString()}
3425
+ ${pending_ops_1.pendingOpBuildCaughtUp.toString()}
3426
+ ${pending_ops_1.pendingLiveRowMatches.toString()}
3427
+ ${pending_ops_1.pendingOpLiveAgrees.toString()}
3428
+ ${pending_ops_1.prunePendingOps.toString()}
3429
+ ${pending_ops_1.pendingExternalMatches.toString()}
3430
+ ${pending_ops_1.pendingExternalRow.toString()}
3431
+ ${pending_ops_1.pendingInternalOf.toString()}
3432
+ ${pending_ops_1.applyPendingOps.toString()}
3433
+ ${pending_ops_1.pendingCapsOf.toString()}
3434
+ ${pending_ops_1.pendingRowNote.toString()}
3435
+ ${pending_ops_1.pendingCountDelta.toString()}
3436
+ ${pending_ops_1.pendingIssuesRef.toString()}
3420
3437
  var STATE_STATUSES = ${JSON.stringify(STATE_STATUSES)};
3421
3438
  var STATE_ROW_FIELDS = ${JSON.stringify(STATE_ROW_FIELDS)};
3422
3439
  ${sanitizeStateNow.toString()}
@@ -6073,6 +6090,91 @@ ${deriveCapTile.toString()}
6073
6090
  * 指すのか説明できなくなる(realtime を別に持っているのと同じ理由)。
6074
6091
  */
6075
6092
  var LIVE_REGISTERED = null;
6093
+ /**
6094
+ * いまの LIVE_REGISTERED を取りに行った時刻(op=state の要求を出した時刻・#388)。
6095
+ * 仮置きの失効「保存より後の live が同じことを言っている」の根拠。届いた時刻ではなく
6096
+ * **出した時刻**——保存前に出した要求の答えが保存後に届いても、保存前の内容だから
6097
+ */
6098
+ var LIVE_REGISTERED_AT = null;
6099
+ /**
6100
+ * 操作タブの仮置き(#388・「ビルド待ちの操作はすべて即時反映」の全面適用)。
6101
+ *
6102
+ * 保存が効いた操作を localStorage に置き、本物(4層合成の結果)に重ねて描く。
6103
+ * 本物が追いついたら prunePendingOps が捨てる。**読み書きはこの4つの関数だけ**が行う。
6104
+ * null は「まだ読んでいない」。読んだ後は配列(空でも)。
6105
+ */
6106
+ var PENDING_OPS = null;
6107
+ function pendingOpsStoreKey() {
6108
+ return pendingOpsKey(SITE.intake ? SITE.intake.projectKey : "");
6109
+ }
6110
+ function readPendingOps() {
6111
+ var key = pendingOpsStoreKey();
6112
+ if (!key) return [];
6113
+ try {
6114
+ var raw = localStorage.getItem(key);
6115
+ return raw ? sanitizePendingOps(JSON.parse(raw), SITE.intake.projectKey) : [];
6116
+ } catch (e) { return []; }
6117
+ }
6118
+ function writePendingOps(list) {
6119
+ var key = pendingOpsStoreKey();
6120
+ if (!key) return;
6121
+ // 書けなくても操作は成功したまま進める(次の取り直し・次のビルドで本物が出る)
6122
+ try {
6123
+ if (list && list.length) localStorage.setItem(key, JSON.stringify(list));
6124
+ else localStorage.removeItem(key);
6125
+ } catch (e) {}
6126
+ }
6127
+ /**
6128
+ * いま効いている仮置き。**呼ぶたびに失効を評価する**(安いので)。減ったら書き戻し、
6129
+ * 合成のキャッシュ(MERGED)も捨てる——捨てないと、失効した仮置きが描かれ続ける
6130
+ */
6131
+ function pendingOpsNow() {
6132
+ if (PENDING_OPS === null) PENDING_OPS = readPendingOps();
6133
+ if (!PENDING_OPS.length) return PENDING_OPS;
6134
+ // 世代は**一覧の下地**(fleet-status の generatedAt)で見る。ページの generatedAt は
6135
+ // 記録の追加でも進むので、それで捨てると下地の古い行が残ったまま仮置きだけ消える
6136
+ var kept = prunePendingOps(PENDING_OPS, {
6137
+ now: Date.now(),
6138
+ siteNowMs: siteTimeMs(rawFleetVal("generatedAt")),
6139
+ live: LIVE_REGISTERED,
6140
+ liveAt: LIVE_REGISTERED_AT,
6141
+ });
6142
+ if (kept.length !== PENDING_OPS.length) {
6143
+ PENDING_OPS = kept;
6144
+ writePendingOps(kept);
6145
+ MERGED = null;
6146
+ }
6147
+ return PENDING_OPS;
6148
+ }
6149
+ /**
6150
+ * 保存が効いた操作を仮置きにする。**成功(2xx かつ changed !== false)のときだけ呼ぶ**
6151
+ * ——失敗した操作を「入っている」ように見せない(#184 の懸念その1)。
6152
+ * 作れたエントリを返す(切り出せない入力では null=従来どおり live の読み直しに任せる)
6153
+ */
6154
+ function notePendingOp(payload, targets) {
6155
+ if (!SITE.intake) return null;
6156
+ var entry = pendingOpOf(payload, {
6157
+ projectKey: SITE.intake.projectKey,
6158
+ now: Date.now(),
6159
+ // 保存時に見ていた一覧の下地の世代(pendingOpsNow の項)。まとめ表が無い案件は空
6160
+ generatedAt: rawFleetVal("generatedAt") || "",
6161
+ // Backlog の解除は送る payload にドメインが無いので、表示中の一覧から補う
6162
+ targets: targets || null,
6163
+ });
6164
+ if (!entry) return null;
6165
+ // **書く前に読み直す。** メモリの一覧に足して書き戻すだけだと、別のタブが書いた仮置きを
6166
+ // 上書きで落とす(同じ案件を 2 タブで操作するのは普通にある)
6167
+ PENDING_OPS = readPendingOps();
6168
+ PENDING_OPS = rememberPendingOp(pendingOpsNow(), entry);
6169
+ writePendingOps(PENDING_OPS);
6170
+ MERGED = null;
6171
+ return entry;
6172
+ }
6173
+ /** 仮置きの行の印(「反映待ち」/「反映を確認できていません」)。印の無い行は null */
6174
+ function pendingBadgeEl(row) {
6175
+ var note = pendingRowNote(row, Date.now());
6176
+ return note ? el("span", { class: "badge ds-pending-badge", text: note }) : null;
6177
+ }
6076
6178
  // 接続状態ストアの行(届くか・取り込めたか)。op=state の stateNow から
6077
6179
  var LIVE_STATE = null;
6078
6180
  /**
@@ -6135,12 +6237,15 @@ ${deriveCapTile.toString()}
6135
6237
  * 足す場所を描画側に散らすと「タイルは1件・一覧は2件」のような食い違いが必ず出る。
6136
6238
  */
6137
6239
  function fleetVal(key) {
6138
- if ((LIVE_REGISTERED || LIVE_STATE) && MERGE_KEYS[key]) {
6139
- // 登録の追加 → 状態の上書き、の順で1回だけ合成する。
6240
+ // 仮置き(#388)は live が無くても合成する——リロード直後(op=state 未着)に
6241
+ // 消したはずの行が出ないため。失効の評価もここで済む(pendingOpsNow の項)
6242
+ var pend = MERGE_KEYS[key] ? pendingOpsNow() : [];
6243
+ if ((LIVE_REGISTERED || LIVE_STATE || pend.length) && MERGE_KEYS[key]) {
6244
+ // 登録の追加 → 仮置き → 状態の上書き、の順で1回だけ合成する。
6140
6245
  // 状態ストアの答えは常に勝つ(まとめ表は gate を焼き込まない。overlayStateOnFleet の項)
6141
6246
  if (!MERGED) {
6142
6247
  MERGED = overlayStateOnFleet(
6143
- mergeRegisteredFleet(rawView(), LIVE_REGISTERED),
6248
+ applyPendingOps(mergeRegisteredFleet(rawView(), LIVE_REGISTERED), pend),
6144
6249
  LIVE_STATE,
6145
6250
  );
6146
6251
  }
@@ -6217,6 +6322,9 @@ ${deriveCapTile.toString()}
6217
6322
 
6218
6323
  /** まとめ表がまだ答えていない行の数(取り直しを続けるかの判断) */
6219
6324
  function unsettledNow() {
6325
+ // ⚠ 仮置き(#388)は数えない。数えると保存のたびに 60 秒の取り直しが 15 分張られ、
6326
+ // 据え置きの古い答えが返る間は GitHub の枠を削るだけになる。仮置きは次の op=state
6327
+ //(タブへ入り直す・モーダルを開く・再読み込み)か次のビルドで失効すれば足りる
6220
6328
  return unsettledRegisteredCount(rawView(), LIVE_REGISTERED);
6221
6329
  }
6222
6330
 
@@ -6239,9 +6347,11 @@ ${deriveCapTile.toString()}
6239
6347
  * どちらも嘘なので、**行と同じ語**(登録状況を確認中です)に倒す。
6240
6348
  */
6241
6349
  function pendingCaps() {
6242
- if (!LIVE_REGISTERED) return {};
6350
+ // 仮置き(#388)を持つ能力も同じ語(登録状況を確認中です)に倒す——add 0 → 1 件に
6351
+ // なった能力に「未接続」と言わせない・remove で 1 → 0 件になった能力に「接続OK」と言わせない
6352
+ var out = pendingCapsOf(pendingOpsNow());
6353
+ if (!LIVE_REGISTERED) return out;
6243
6354
  var add = registeredRowsToAdd(rawView(), LIVE_REGISTERED);
6244
- var out = {};
6245
6355
  for (var i = 0; i < add.externalSources.length; i++) {
6246
6356
  var addType = add.externalSources[i].type;
6247
6357
  out[externalCapability(addType)] = 1;
@@ -6348,11 +6458,14 @@ ${deriveCapTile.toString()}
6348
6458
  // アダプタが分かるタイルにまで合算を掛けると、相手の名簿の答えが自分の件数に混ざる
6349
6459
  //(gc の名簿が空=0 が、まだ読めていない chat の null を上書きして Slack を未接続にする)。
6350
6460
  // 片方しか読めなかった回は読めた方だけで数える(両方 null のときだけ null)
6461
+ // 仮置き(#388)の分を加減する。一覧だけ足し引きしてタイルの件数を触らないと、
6462
+ // 「行は消えたのにタイルは登録済み 1 件」「行は 1 件なのにタイルは未接続」が並ぶ
6463
+ var pendOps = pendingOpsNow();
6351
6464
  if (cap === "チャット" && !adapter) {
6352
6465
  var a = LIVE_REGISTERED["chat"];
6353
6466
  var b = LIVE_REGISTERED["google-chat"];
6354
- var an = Array.isArray(a) ? a.length : null;
6355
- var bn = Array.isArray(b) ? b.length : null;
6467
+ var an = Array.isArray(a) ? a.length + pendingCountDelta(pendOps, LIVE_REGISTERED, "chat") : null;
6468
+ var bn = Array.isArray(b) ? b.length + pendingCountDelta(pendOps, LIVE_REGISTERED, "google-chat") : null;
6356
6469
  // **片方でも読めなかった(null)なら合算も null(#153 実装レビュー4)。** null を 0 に
6357
6470
  // 畳むと、channels.json の読み取りが失敗した回に「登録0件→未接続」と断定する——
6358
6471
  // サーバの契約(読めなかった能力のキーは出さない=0件と混ぜない)をここで壊さない
@@ -6360,7 +6473,7 @@ ${deriveCapTile.toString()}
6360
6473
  return an + bn;
6361
6474
  }
6362
6475
  var list = LIVE_REGISTERED[meta.id];
6363
- return Array.isArray(list) ? list.length : null;
6476
+ return Array.isArray(list) ? list.length + pendingCountDelta(pendOps, LIVE_REGISTERED, meta.id) : null;
6364
6477
  }
6365
6478
 
6366
6479
  /**
@@ -8133,6 +8246,8 @@ ${deriveCapTile.toString()}
8133
8246
  if (s.workspace) payload.workspace = s.workspace;
8134
8247
  callIntake("config", payload).then(function (res) {
8135
8248
  if (!res.ok) return;
8249
+ // 仮置き(#388): リロードしても新しい名前で出る。送っていない name を判定用に添える
8250
+ notePendingOp({ op: "refresh-channel-name", ref: s.ref, name: r.name });
8136
8251
  // 一覧は夜間のスナップショットなので、その場では変わらない。行だけ追いつかせる
8137
8252
  var nameEl = card.querySelector(".ds-name");
8138
8253
  if (nameEl) nameEl.textContent = slackDisplayName(r.name);
@@ -8378,7 +8493,12 @@ ${deriveCapTile.toString()}
8378
8493
  var out = [];
8379
8494
  for (var i = 0; i < raw.length; i++) {
8380
8495
  var x = raw[i];
8381
- if (x && x.domain && x.projectKey) out.push({ domain: String(x.domain), projectKey: String(x.projectKey) });
8496
+ if (x && x.domain && x.projectKey) {
8497
+ var row = { domain: String(x.domain), projectKey: String(x.projectKey) };
8498
+ // 仮置きの印(#388)は行に持ち回る(targetsGenerationOf は domain / projectKey しか見ない)
8499
+ if (x.pending === true) { row.pending = true; row.pendingAt = x.pendingAt; }
8500
+ out.push(row);
8501
+ }
8382
8502
  }
8383
8503
  if (out.length) return out;
8384
8504
  // 🔴 **正本(issues-config.json)が読めていて0件のときは、url から復元しない(段A補)。**
@@ -8575,6 +8695,10 @@ ${deriveCapTile.toString()}
8575
8695
  return r ? r.status + "@" + r.at : "-";
8576
8696
  }).join(",");
8577
8697
  }
8698
+ /** 取り直しで描き直すかの署名: 取り込みの状態+仮置きの印(#388・印だけ残さないため) */
8699
+ function backlogPaintSigOf(list) {
8700
+ return ingestSigOf(list) + "|" + (list || []).map(function (t) { return t && t.pending === true ? "p" : "-"; }).join(",");
8701
+ }
8578
8702
 
8579
8703
  /**
8580
8704
  * Figmaファイル1件の「取り込み」の行(接続状態ストア・段C第二弾)。**無ければ null。**
@@ -8658,6 +8782,8 @@ ${deriveCapTile.toString()}
8658
8782
  el("span", { class: "ds-type", text: "Backlog" }),
8659
8783
  dsNameEl(t.domain + " / " + t.projectKey, "https://" + t.domain + "/projects/" + t.projectKey),
8660
8784
  ]),
8785
+ // 仮置きの行(#388)の印。無ければ null
8786
+ pendingBadgeEl(t),
8661
8787
  // この接続先の取り込みが最後にどうなったか(段C)。測っていなければ null
8662
8788
  ingestedEl(ingestedStateOf(t.domain, t.projectKey), "課題管理"),
8663
8789
  ]);
@@ -8832,7 +8958,12 @@ ${deriveCapTile.toString()}
8832
8958
  }
8833
8959
 
8834
8960
  function targetsHooks() {
8835
- return { onTargets: adoptTargets, staleNote: TARGETS_RACE_NOTE };
8961
+ return {
8962
+ onTargets: adoptTargets,
8963
+ staleNote: TARGETS_RACE_NOTE,
8964
+ // 仮置き(#388)の鍵にドメインを入れるため、表示中の一覧を渡す(解除の payload にはキーしか無い)
8965
+ pendingTargets: function () { return targets; },
8966
+ };
8836
8967
  }
8837
8968
 
8838
8969
  // **鍵の欄の受け皿を、行のスペースごとに1つ用意する(2026-08-21・オーナー指摘)。**
@@ -8872,7 +9003,7 @@ ${deriveCapTile.toString()}
8872
9003
  var box = el("div", { "data-live-cap": REGISTERED_CAP_NAMES.issues }, [listEl]);
8873
9004
  paint();
8874
9005
  /** いま描いてある取り込みの状態(段C)。接続先の集合とは別に変わるので、別に覚える */
8875
- var lastIngestSig = ingestSigOf(targets);
9006
+ var lastIngestSig = backlogPaintSigOf(targets);
8876
9007
  /**
8877
9008
  * 取り直しで新しい値が届いたときの差し替え(段階1)。**一覧だけを描き直す。**
8878
9009
  *
@@ -8887,7 +9018,7 @@ ${deriveCapTile.toString()}
8887
9018
  // 🔴 **変化が無いと言えるのは、接続先の集合と取り込みの状態が両方同じときだけ(段C)。**
8888
9019
  // 世代だけで判断すると、**応答を待たずにモーダルを開いた人には取り込みが永久に出ない**
8889
9020
  //(届いた応答で集合は変わらないので、ここで飛ばされる)。ingestSigOf の項に経緯
8890
- var sig = ingestSigOf(next);
9021
+ var sig = backlogPaintSigOf(next);
8891
9022
  if (targetsGenerationOf(next) === targetsGenerationOf(targets) && sig === lastIngestSig) return;
8892
9023
  targets = next;
8893
9024
  lastIngestSig = sig;
@@ -9071,7 +9202,8 @@ ${deriveCapTile.toString()}
9071
9202
  var driveNow = function () { return internalSourceOf("共有資料") || s; };
9072
9203
  box.appendChild(liveList("共有資料", function () {
9073
9204
  var d = driveNow();
9074
- return JSON.stringify([d.driveFolderIds || [], d.driveFolderNames || {}, d.driveState || ""]);
9205
+ // 仮置きの印(drivePending)も署名に入れる——外れたときに印だけ残さない(#388)
9206
+ return JSON.stringify([d.driveFolderIds || [], d.driveFolderNames || {}, d.driveState || "", d.drivePending || {}]);
9075
9207
  }, function (listBox) {
9076
9208
  var src = driveNow();
9077
9209
  var folders = src.driveFolderIds || [];
@@ -9091,6 +9223,10 @@ ${deriveCapTile.toString()}
9091
9223
  el("span", { class: "ds-type", text: "Google Drive" }),
9092
9224
  dsNameEl(fname || id, "https://drive.google.com/drive/folders/" + id),
9093
9225
  ]),
9226
+ // 仮置きの行(#388)の印。Drive は行が ID 文字列なので drivePending から引く
9227
+ pendingBadgeEl(src.drivePending && src.drivePending[id] != null
9228
+ ? { pending: true, pendingAt: src.drivePending[id] }
9229
+ : null),
9094
9230
  ]);
9095
9231
  // **名前を出したときはIDも併記する。** 同名のフォルダを見分けられなくなるのと、
9096
9232
  // 「このIDを外す」という確認文と画面の表示が食い違うのを防ぐ
@@ -9233,6 +9369,8 @@ ${deriveCapTile.toString()}
9233
9369
  el("span", { class: "ds-type", text: "Figma" }),
9234
9370
  dsNameEl(f.name || f.key, "https://www.figma.com/design/" + f.key),
9235
9371
  ]),
9372
+ // 仮置きの行(#388)の印。無ければ null
9373
+ pendingBadgeEl(f),
9236
9374
  // このファイルの取り込みが最後にどうなったか(段C)。測っていなければ null
9237
9375
  ingestedEl(designIngestedStateOf(f.key), "デザイン"),
9238
9376
  ]);
@@ -9632,9 +9770,16 @@ ${deriveCapTile.toString()}
9632
9770
  endBusy();
9633
9771
  return;
9634
9772
  }
9773
+ // 🔴 **仮置き(#388)はここで作る**(文面の前)。作れたときはサーバの文面を**先頭の 1 文だけ**に
9774
+ // する——続きの「一覧への反映には数分かかります(再読み込みで最新になります)」は、
9775
+ // その場で「反映待ち」の行が出ている横では嘘(intake の文面は本便では触らない)。
9776
+ // **changed:false では書かない**(何も変わっていないのに追加したと見せない)
9777
+ var pendingEntry = j.changed !== false
9778
+ ? notePendingOp(payload, hooks && typeof hooks.pendingTargets === "function" ? hooks.pendingTargets() : null)
9779
+ : null;
9635
9780
  msg.className = "ds-toggle-msg is-ok";
9636
9781
  msg.textContent = "";
9637
- msg.appendChild(document.createTextNode("✓ " + (j.message || "保存しました。")));
9782
+ msg.appendChild(document.createTextNode("✓ " + ((pendingEntry ? noteHeadline(j.message) : j.message) || "保存しました。")));
9638
9783
  // **起動できたときだけ導線を出す。** 押した直後に「効いたか」を確かめられるのが要点。
9639
9784
  // 起動できなかった案件(権限・スタブ未配布)ではリンクを出さない——押しても何も無い
9640
9785
  if (j.runsUrl) {
@@ -9663,6 +9808,18 @@ ${deriveCapTile.toString()}
9663
9808
  //
9664
9809
  // **既存4呼び出しの onDone 意味論には触れない。** 使い分けは呼び出し側の責任で、
9665
9810
  // 冪等な op(棚の2つ)だけがこちらを使う。
9811
+ // 🔴 **仮置き(#388)の反映。** add は行を足す・remove は行を消す・update は欄を差し替える。
9812
+ // add / remove は開いている一覧を描き直す。update(通知先の入り切り・名前)は
9813
+ // 呼び出し側がその場で行を直しているので、描き直して「✓」を消さない。
9814
+ // remove は行ごと「✓ 解除しました」が消えるので、先頭の 1 文をトーストで出す
9815
+ if (pendingEntry && pendingEntry.action !== "update") {
9816
+ // 応答が一覧を添えてくる口(Backlog の onTargets)は結果の表示が一覧の外にあるので、重ねない
9817
+ if (pendingEntry.action === "remove" && !(hooks && hooks.onTargets)) {
9818
+ showToast(noteHeadline(j.message) || "登録を解除しました。");
9819
+ }
9820
+ repaintOpenLists();
9821
+ refreshDataSources();
9822
+ }
9666
9823
  if (hooks && typeof hooks.onSaved === "function") hooks.onSaved(j);
9667
9824
  // **保存が効いたら、いまの登録を読み直す(段階1)。** 一覧はまとめ表(夜間バッチ)から
9668
9825
  // 描かれるので、保存しただけでは行が出ない。ここで読み直すと、登録ファイルの中身が
@@ -9677,7 +9834,8 @@ ${deriveCapTile.toString()}
9677
9834
  // 正しく反映できる。**流れるのは res.ok かつ changed !== false のときだけ**で、
9678
9835
  // removeButton 経由の onDone(引数を取らない)には届かない——受け側は
9679
9836
  // j が undefined でも動く形に保つこと(既存の4呼び出しは引数を取らない)
9680
- if (onDone && j.changed !== false) onDone(j);
9837
+ // 第2引数は仮置きの結果(#388)。控えを出すかの判断に使う(addForm)。既存の受け側は見ない
9838
+ if (onDone && j.changed !== false) onDone(j, { pending: pendingEntry });
9681
9839
  } catch (e) {
9682
9840
  msg.className = "ds-toggle-msg is-error";
9683
9841
  msg.textContent = "✗ 通信に失敗しました。";
@@ -9685,6 +9843,13 @@ ${deriveCapTile.toString()}
9685
9843
  }
9686
9844
  }
9687
9845
 
9846
+ /** サーバの文面の先頭の1文(「。」まで)。空なら空文字 */
9847
+ function noteHeadline(text) {
9848
+ var s = String(text || "");
9849
+ var i = s.indexOf("。");
9850
+ return i < 0 ? s : s.slice(0, i + 1);
9851
+ }
9852
+
9688
9853
  /**
9689
9854
  * 危険な方向にだけ出す確認。**何が起きるかを具体に書く**(「よろしいですか」だけにしない)。
9690
9855
  *
@@ -9952,12 +10117,15 @@ ${deriveCapTile.toString()}
9952
10117
  var added = main.value.trim();
9953
10118
  var addedName = name ? name.value.trim() : "";
9954
10119
  var payload = opts.build(added, addedName, wsSelect ? wsSelect.value : "");
9955
- sendConfig(payload, msg, btn, function () {
10120
+ sendConfig(payload, msg, btn, function (j, info) {
9956
10121
  // 追加できたら入力欄を空にする(同じものを二度押ししないように)
9957
10122
  main.value = "";
9958
10123
  if (name) name.value = "";
9959
10124
  // 応答で本物の一覧が描き直される口では、控えを出さない(同じものが2回並ぶ)
9960
10125
  if (opts.hooks) return;
10126
+ // 仮置き(#388)が作れたときも同じ——一覧に「反映待ち」の行が出ている。
10127
+ // 作れなかった(URL を切り出せなかった)ときだけ従来の控えに落ちる
10128
+ if (info && info.pending) return;
9961
10129
  // **追加したものをその場に出す。** 上の一覧は fleet-status.json(夜間スナップショット)
9962
10130
  // から描かれているので、追加してもその場では変わらない。何も変わらないと
9963
10131
  // 「追加できていない」と読まれ、もう一度押して「既に登録されています」になる
@@ -10085,7 +10253,9 @@ ${deriveCapTile.toString()}
10085
10253
  // 折りたたみの見出しでまとめるのをやめた(オーナー指摘「複雑になっちゃってる」)ので、
10086
10254
  // **どの組織かを言うのは行のラベルだけ**になった。社内に何も出さないと、
10087
10255
  // ラベルの無い行が「社内」なのか「まだ分かっていない」のかを読み分けられない。
10088
- if (s.type === "slack") {
10256
+ // **仮置きの行(#388)には出さない。** どの組織かはサーバが追加時に実測して書くが
10257
+ // 応答には載らないので、ここで「クラスメソッド」と名乗ると嘘になる(本物の行が来れば付く)
10258
+ if (s.type === "slack" && s.pending !== true) {
10089
10259
  // **順序は groupSlackByOrg と揃える(workspace が先)。**
10090
10260
  // 逆にすると、判定が済んで workspace が入った行に古い印が残っているとき、
10091
10261
  // **並びは「Hapio AI」なのにラベルだけ「組織が分かりません」**になる
@@ -10147,7 +10317,12 @@ ${deriveCapTile.toString()}
10147
10317
  //
10148
10318
  // ⚠ **s.origin 自体は残す。** 「登録から削除」を出すかどうかの判定に使っている
10149
10319
  //(自動検出の行に削除を出すと、押しても翌日戻ってくる)。**札を出さないだけ**
10150
- var gate = dsGateOf(s);
10320
+ // 仮置きの行(#388)は状態の語を「反映待ち」に**差し替える**(Slack / Google Chat /
10321
+ // GitHub の3種はここ1箇所)。gate が無い行は「登録状況を確認中です」になるので、
10322
+ // そこへ札を足すと待ちの語が2つ並ぶ
10323
+ var gate = s.pending === true
10324
+ ? { text: pendingRowNote(s, Date.now()), cls: DS_GATE_PENDING.cls }
10325
+ : dsGateOf(s);
10151
10326
  // **「接続を確認」が押されたら、この要素を live の判定で置き換える**(probeSection)。
10152
10327
  // 2つの判定を同じ行に並べないため、参照を持っておく
10153
10328
  var gateEl = el("span", { class: "ds-gate " + gate.cls, text: gate.text });
@@ -10188,8 +10363,10 @@ ${deriveCapTile.toString()}
10188
10363
  if (SITE.intake && s.type === "slack" && s.ref && s.origin !== "explicit") {
10189
10364
  card.appendChild(removeChannelButton(s, card));
10190
10365
  // 通知先の入り切り。**解除と同じ条件で出す**——書き換える先は違う(notify.json)が、
10191
- // どちらも「チャット/channels.json に登録がある行」でしか効かない
10192
- card.appendChild(notifyToggle(s, badges));
10366
+ // どちらも「チャット/channels.json に登録がある行」でしか効かない。
10367
+ // 仮置きの行(#388)には出さない——ワークスペースが未確定で、顧客ワークスペースなら
10368
+ // 押しても断られる(本物の行が来てから)
10369
+ if (s.pending !== true) card.appendChild(notifyToggle(s, badges));
10193
10370
  }
10194
10371
  // Google Chat のスペースの登録解除(#153)。#155 のモデル(載っていること自体が
10195
10372
  // 読み取りの意思決定・止める手段は登録解除)に Slack と同型で揃える。
@@ -10600,6 +10777,8 @@ ${deriveCapTile.toString()}
10600
10777
  // 捨てる(断らない)ので、画面と Lambda の版が食い違っても接続状況の表示は消えない
10601
10778
  // **保存直後だけ据え置きを迂回させる。** これが無いと、サーバが保存前の答えを返し、
10602
10779
  // 足りない行が0件になって取り直しも張られない=手で再読み込みするまで追いつかない
10780
+ // 仮置きの失効判定は「要求を出した時刻」で見る(LIVE_REGISTERED_AT の項)
10781
+ var startedAt = Date.now();
10603
10782
  callIntake(
10604
10783
  "state",
10605
10784
  useFresh ? { caps: ALL_STATE_CAPS, fresh: true } : { caps: ALL_STATE_CAPS },
@@ -10623,6 +10802,7 @@ ${deriveCapTile.toString()}
10623
10802
  // (=立ち上げ直後=この仕組みが一番効く案件)は下で throw するので、
10624
10803
  // ここより後に置くと**その案件だけ素通り**する
10625
10804
  LIVE_REGISTERED = sanitizeRegisteredNow(j && j.registeredNow);
10805
+ LIVE_REGISTERED_AT = startedAt;
10626
10806
  LIVE_STATE = sanitizeStateNow(j && j.stateNow);
10627
10807
  // **欄の有無をそのまま覚える。** sanitize を通した後では「読めて0件」が消える
10628
10808
  LIVE_STATE_SEEN = !!(j && Array.isArray(j.stateNow));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takagaki/cortex-decisions-viewer",
3
- "version": "0.12.29",
3
+ "version": "0.12.30",
4
4
  "description": "Cortexの意思決定記録(Decisions/*.md)を静的サイトにビルドして閲覧する。各レコードに「Edit on GitHub」リンクを付与する。",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,