@takagaki/cortex-decisions-viewer 0.12.28 → 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.
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()}
@@ -5027,8 +5044,8 @@ ${deriveCapTile.toString()}
5027
5044
  };
5028
5045
  /**
5029
5046
  * 表を引くときのアダプタ種別。**probeTypeOf を使わない**——あちらは「op=probe の受け口」の
5030
- * 語彙で、Google Chat に空文字を返す(実測の口が無いため)。ここで使うと Google Chat の
5031
- * エントリを永久に置けない
5047
+ * 語彙で、「github-issues」 / 「github-discussions」 を 「github」 に畳むところまでは同じだが、
5048
+ * **実測の口が無い種別に空文字を返す**(この表は実測の有無と無関係にエントリを置く)
5032
5049
  */
5033
5050
  function helpTypeOf(type) {
5034
5051
  var t = String(type || "");
@@ -5395,6 +5412,18 @@ ${deriveCapTile.toString()}
5395
5412
  * 読み取れない——下の説明文は「誰が承認できるか」の話しかしていない。
5396
5413
  * 1行だけ添える口を開けておく。**見出しは付けない**(節を増やすと一覧が遠くなる)。
5397
5414
  *
5415
+ * ## 承認画面から戻ってきたとき(#343)
5416
+ *
5417
+ * 成功経路は同じタブで遷移するので、**このハンドラには戻す機会が無い**——
5418
+ * 「準備しています…」と disabled を立てたまま画面を離れる。ブラウザバックで
5419
+ * 戻ると bfcache が**離脱直前の DOM とスクリプトの状態をそのまま復元する**ので、
5420
+ * その固まった見た目が再現し、もう一度押せなくなる。だから復帰は
5421
+ * bfcache 復元でだけ発火する pageshow(persisted)で受ける。
5422
+ * 戻す口は**起動時に1本だけ張った listener が data-leaving を DOM から探す**形にした
5423
+ * ——モーダルを開くたびにこのボタンは作り直されるので、登録簿に貯めると
5424
+ * 閉じたモーダルの DOM を掴み続ける。印を付けるのは遷移が確定した瞬間だけで、
5425
+ * 失敗経路(その場でボタンを戻す)には付けない。
5426
+ *
5398
5427
  * @param lead 省略可。押す理由の1行(案内の中から呼ぶときは渡さない)
5399
5428
  */
5400
5429
  function slackConnectButton(lead) {
@@ -5423,6 +5452,11 @@ ${deriveCapTile.toString()}
5423
5452
  }
5424
5453
  // **同じタブで遷移する。** 別タブだと、承認後の「接続しました」を見た人が
5425
5454
  // 元のタブに戻る動線を自分で見つけることになる
5455
+ //
5456
+ // **遷移が確定したこの瞬間にだけ印を付ける(#343)。** ブラウザバックで
5457
+ // bfcache から復元されたとき、起動時の pageshow の listener がこの印を頼りに
5458
+ // ボタンと「準備しています…」を素の状態へ戻す(restoreLeavingButtons)
5459
+ btn.setAttribute("data-leaving", "1");
5426
5460
  location.href = j.url;
5427
5461
  } catch (e) {
5428
5462
  msg.className = "ds-toggle-msg is-error";
@@ -6056,6 +6090,91 @@ ${deriveCapTile.toString()}
6056
6090
  * 指すのか説明できなくなる(realtime を別に持っているのと同じ理由)。
6057
6091
  */
6058
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
+ }
6059
6178
  // 接続状態ストアの行(届くか・取り込めたか)。op=state の stateNow から
6060
6179
  var LIVE_STATE = null;
6061
6180
  /**
@@ -6118,12 +6237,15 @@ ${deriveCapTile.toString()}
6118
6237
  * 足す場所を描画側に散らすと「タイルは1件・一覧は2件」のような食い違いが必ず出る。
6119
6238
  */
6120
6239
  function fleetVal(key) {
6121
- if ((LIVE_REGISTERED || LIVE_STATE) && MERGE_KEYS[key]) {
6122
- // 登録の追加 → 状態の上書き、の順で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回だけ合成する。
6123
6245
  // 状態ストアの答えは常に勝つ(まとめ表は gate を焼き込まない。overlayStateOnFleet の項)
6124
6246
  if (!MERGED) {
6125
6247
  MERGED = overlayStateOnFleet(
6126
- mergeRegisteredFleet(rawView(), LIVE_REGISTERED),
6248
+ applyPendingOps(mergeRegisteredFleet(rawView(), LIVE_REGISTERED), pend),
6127
6249
  LIVE_STATE,
6128
6250
  );
6129
6251
  }
@@ -6200,6 +6322,9 @@ ${deriveCapTile.toString()}
6200
6322
 
6201
6323
  /** まとめ表がまだ答えていない行の数(取り直しを続けるかの判断) */
6202
6324
  function unsettledNow() {
6325
+ // ⚠ 仮置き(#388)は数えない。数えると保存のたびに 60 秒の取り直しが 15 分張られ、
6326
+ // 据え置きの古い答えが返る間は GitHub の枠を削るだけになる。仮置きは次の op=state
6327
+ //(タブへ入り直す・モーダルを開く・再読み込み)か次のビルドで失効すれば足りる
6203
6328
  return unsettledRegisteredCount(rawView(), LIVE_REGISTERED);
6204
6329
  }
6205
6330
 
@@ -6222,9 +6347,11 @@ ${deriveCapTile.toString()}
6222
6347
  * どちらも嘘なので、**行と同じ語**(登録状況を確認中です)に倒す。
6223
6348
  */
6224
6349
  function pendingCaps() {
6225
- 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;
6226
6354
  var add = registeredRowsToAdd(rawView(), LIVE_REGISTERED);
6227
- var out = {};
6228
6355
  for (var i = 0; i < add.externalSources.length; i++) {
6229
6356
  var addType = add.externalSources[i].type;
6230
6357
  out[externalCapability(addType)] = 1;
@@ -6331,11 +6458,14 @@ ${deriveCapTile.toString()}
6331
6458
  // アダプタが分かるタイルにまで合算を掛けると、相手の名簿の答えが自分の件数に混ざる
6332
6459
  //(gc の名簿が空=0 が、まだ読めていない chat の null を上書きして Slack を未接続にする)。
6333
6460
  // 片方しか読めなかった回は読めた方だけで数える(両方 null のときだけ null)
6461
+ // 仮置き(#388)の分を加減する。一覧だけ足し引きしてタイルの件数を触らないと、
6462
+ // 「行は消えたのにタイルは登録済み 1 件」「行は 1 件なのにタイルは未接続」が並ぶ
6463
+ var pendOps = pendingOpsNow();
6334
6464
  if (cap === "チャット" && !adapter) {
6335
6465
  var a = LIVE_REGISTERED["chat"];
6336
6466
  var b = LIVE_REGISTERED["google-chat"];
6337
- var an = Array.isArray(a) ? a.length : null;
6338
- 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;
6339
6469
  // **片方でも読めなかった(null)なら合算も null(#153 実装レビュー4)。** null を 0 に
6340
6470
  // 畳むと、channels.json の読み取りが失敗した回に「登録0件→未接続」と断定する——
6341
6471
  // サーバの契約(読めなかった能力のキーは出さない=0件と混ぜない)をここで壊さない
@@ -6343,7 +6473,7 @@ ${deriveCapTile.toString()}
6343
6473
  return an + bn;
6344
6474
  }
6345
6475
  var list = LIVE_REGISTERED[meta.id];
6346
- return Array.isArray(list) ? list.length : null;
6476
+ return Array.isArray(list) ? list.length + pendingCountDelta(pendOps, LIVE_REGISTERED, meta.id) : null;
6347
6477
  }
6348
6478
 
6349
6479
  /**
@@ -7861,6 +7991,10 @@ ${deriveCapTile.toString()}
7861
7991
  * **開発(GitHub)は #234 で足した。** 判定は shared/github-gate.mjs、次の一手は
7862
7992
  * サーバの needed(理由と設定ページのURLを1文で持つ)をそのまま出している
7863
7993
  * ——画面に分岐が生まれないよう、言い分けを全部あちらに置いた
7994
+ *
7995
+ * **Google Chat は #347 で足した。** 判定は shared/google-chat-gate.mjs で、夜間の実測と
7996
+ * 同じ叩き先・同じスコープで測る。次の一手(googleChatProbeHelpNodes)は行の常時表示と
7997
+ * 同じ表(DS_GATE の help)から引くだけで、画面に判定の分岐は無い
7864
7998
  * ------------------------------------------------------------------------- */
7865
7999
 
7866
8000
  /**
@@ -7879,9 +8013,13 @@ ${deriveCapTile.toString()}
7879
8013
  * 確認したいアダプタの種別(「op=probe」 の 「type」)。**画面の種別をそのまま送らない**
7880
8014
  * ——「github-issues」 / 「github-discussions」 は同じ1つのリポジトリの読み取りで、
7881
8015
  * サーバの受け口は 「github」 の1種別しか知らない(「PROBE_REF_RULES」)。
8016
+ *
8017
+ * **空文字を返した種別にはボタンが出ない**(下の 「extSourceCard」 の条件)。
8018
+ * Google Chat は #347 で足した——サーバの受け口の綴りがそのまま種別になる。
7882
8019
  */
7883
8020
  function probeTypeOf(type) {
7884
8021
  if (String(type || "").indexOf("github") === 0) return "github";
8022
+ if (type === "google-chat") return "google-chat";
7885
8023
  return type === "slack" ? "slack" : "";
7886
8024
  }
7887
8025
 
@@ -7936,6 +8074,36 @@ ${deriveCapTile.toString()}
7936
8074
  return [el("p", { class: "ds-probe-help", text: "GitHub の応答:" + (r.error || "不明") + "。時間をおいて再度お試しください。" })];
7937
8075
  }
7938
8076
 
8077
+ /**
8078
+ * Google Chat の確認結果の本文(#347)。
8079
+ *
8080
+ * **判定は infra/lambda/shared/google-chat-gate.mjs にしか無い。** ここが返すのは
8081
+ * 「次の一手」だけで、状態の語(バッジ)は probeVerdict が dsGateOf から取る。
8082
+ *
8083
+ * **文を書き写さない。** 「アプリと統合から追加してください」「管理者の設定が済むと…」は
8084
+ * 行の常時表示(DS_GATE の 「help」)が既に持っている——写経すると、同じ行の常時表示と
8085
+ * 押した結果で語が割れる(この節が何度も直してきた形)。DS_GATE から引いて1文だけ出す。
8086
+ *
8087
+ * ⚠ **403(Workspace 管理者の承認待ち)は Phase 0 の採取まで 「unknown」。** その回は
8088
+ * サーバの機械語(「permission_denied」)をそのまま出す。**「時間をおいて」と言わない**
8089
+ * ——待っても直らない(管理者が承認するまで同じ応答が返る)。
8090
+ */
8091
+ function googleChatProbeHelpNodes(s, r) {
8092
+ var p = function (t) { return el("p", { class: "ds-probe-help", text: t }); };
8093
+ // 成功。**1行で終わり**(状態の語が既に「接続OK」と出ている)
8094
+ if (r.gate === "ok") return [];
8095
+ // not_in_space / no_credentials は行の常時表示と同じ1文(DS_GATE から引く)。
8096
+ // 押した瞬間に行側の 「.ds-gate-help」 は隠れるので、二重には出ない
8097
+ var help = dsGateOf({ type: s.type, gate: r.gate }).help;
8098
+ if (help) return [p(help)];
8099
+ if (r.gate === "unreachable") {
8100
+ // 帯(「アクセス不可」)が症状を言っている。**ここは一手だけ**
8101
+ return [p("時間をおいて、もう一度「接続を確認」を押してください。")];
8102
+ }
8103
+ // unknown。**生の機械語を隠さない**——「不明」だけだと切り分けが始められない
8104
+ return [p("Google Chat の応答:" + (r.error || "不明") + "。cortex の担当者にこの語をお伝えください。")];
8105
+ }
8106
+
7939
8107
  /**
7940
8108
  * 「アクセス不可」の次の一手(0.12.2・実オンボーディングで踏んだ)。
7941
8109
  *
@@ -8012,6 +8180,7 @@ ${deriveCapTile.toString()}
8012
8180
  function probeHelpNodes(s, r) {
8013
8181
  var p = function (t) { return el("p", { class: "ds-probe-help", text: t }); };
8014
8182
  if (probeTypeOf(s.type) === "github") return githubProbeHelpNodes(r);
8183
+ if (probeTypeOf(s.type) === "google-chat") return googleChatProbeHelpNodes(s, r);
8015
8184
  // 成功。**1行で終わり。** 状態の語(「接続OK」)が既に出ているので本文は空
8016
8185
  if (r.gate === "ok") return [];
8017
8186
  if (r.gate === "not_in_channel") {
@@ -8077,6 +8246,8 @@ ${deriveCapTile.toString()}
8077
8246
  if (s.workspace) payload.workspace = s.workspace;
8078
8247
  callIntake("config", payload).then(function (res) {
8079
8248
  if (!res.ok) return;
8249
+ // 仮置き(#388): リロードしても新しい名前で出る。送っていない name を判定用に添える
8250
+ notePendingOp({ op: "refresh-channel-name", ref: s.ref, name: r.name });
8080
8251
  // 一覧は夜間のスナップショットなので、その場では変わらない。行だけ追いつかせる
8081
8252
  var nameEl = card.querySelector(".ds-name");
8082
8253
  if (nameEl) nameEl.textContent = slackDisplayName(r.name);
@@ -8322,7 +8493,12 @@ ${deriveCapTile.toString()}
8322
8493
  var out = [];
8323
8494
  for (var i = 0; i < raw.length; i++) {
8324
8495
  var x = raw[i];
8325
- 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
+ }
8326
8502
  }
8327
8503
  if (out.length) return out;
8328
8504
  // 🔴 **正本(issues-config.json)が読めていて0件のときは、url から復元しない(段A補)。**
@@ -8519,6 +8695,10 @@ ${deriveCapTile.toString()}
8519
8695
  return r ? r.status + "@" + r.at : "-";
8520
8696
  }).join(",");
8521
8697
  }
8698
+ /** 取り直しで描き直すかの署名: 取り込みの状態+仮置きの印(#388・印だけ残さないため) */
8699
+ function backlogPaintSigOf(list) {
8700
+ return ingestSigOf(list) + "|" + (list || []).map(function (t) { return t && t.pending === true ? "p" : "-"; }).join(",");
8701
+ }
8522
8702
 
8523
8703
  /**
8524
8704
  * Figmaファイル1件の「取り込み」の行(接続状態ストア・段C第二弾)。**無ければ null。**
@@ -8602,6 +8782,8 @@ ${deriveCapTile.toString()}
8602
8782
  el("span", { class: "ds-type", text: "Backlog" }),
8603
8783
  dsNameEl(t.domain + " / " + t.projectKey, "https://" + t.domain + "/projects/" + t.projectKey),
8604
8784
  ]),
8785
+ // 仮置きの行(#388)の印。無ければ null
8786
+ pendingBadgeEl(t),
8605
8787
  // この接続先の取り込みが最後にどうなったか(段C)。測っていなければ null
8606
8788
  ingestedEl(ingestedStateOf(t.domain, t.projectKey), "課題管理"),
8607
8789
  ]);
@@ -8776,7 +8958,12 @@ ${deriveCapTile.toString()}
8776
8958
  }
8777
8959
 
8778
8960
  function targetsHooks() {
8779
- 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
+ };
8780
8967
  }
8781
8968
 
8782
8969
  // **鍵の欄の受け皿を、行のスペースごとに1つ用意する(2026-08-21・オーナー指摘)。**
@@ -8816,7 +9003,7 @@ ${deriveCapTile.toString()}
8816
9003
  var box = el("div", { "data-live-cap": REGISTERED_CAP_NAMES.issues }, [listEl]);
8817
9004
  paint();
8818
9005
  /** いま描いてある取り込みの状態(段C)。接続先の集合とは別に変わるので、別に覚える */
8819
- var lastIngestSig = ingestSigOf(targets);
9006
+ var lastIngestSig = backlogPaintSigOf(targets);
8820
9007
  /**
8821
9008
  * 取り直しで新しい値が届いたときの差し替え(段階1)。**一覧だけを描き直す。**
8822
9009
  *
@@ -8831,7 +9018,7 @@ ${deriveCapTile.toString()}
8831
9018
  // 🔴 **変化が無いと言えるのは、接続先の集合と取り込みの状態が両方同じときだけ(段C)。**
8832
9019
  // 世代だけで判断すると、**応答を待たずにモーダルを開いた人には取り込みが永久に出ない**
8833
9020
  //(届いた応答で集合は変わらないので、ここで飛ばされる)。ingestSigOf の項に経緯
8834
- var sig = ingestSigOf(next);
9021
+ var sig = backlogPaintSigOf(next);
8835
9022
  if (targetsGenerationOf(next) === targetsGenerationOf(targets) && sig === lastIngestSig) return;
8836
9023
  targets = next;
8837
9024
  lastIngestSig = sig;
@@ -9015,7 +9202,8 @@ ${deriveCapTile.toString()}
9015
9202
  var driveNow = function () { return internalSourceOf("共有資料") || s; };
9016
9203
  box.appendChild(liveList("共有資料", function () {
9017
9204
  var d = driveNow();
9018
- return JSON.stringify([d.driveFolderIds || [], d.driveFolderNames || {}, d.driveState || ""]);
9205
+ // 仮置きの印(drivePending)も署名に入れる——外れたときに印だけ残さない(#388)
9206
+ return JSON.stringify([d.driveFolderIds || [], d.driveFolderNames || {}, d.driveState || "", d.drivePending || {}]);
9019
9207
  }, function (listBox) {
9020
9208
  var src = driveNow();
9021
9209
  var folders = src.driveFolderIds || [];
@@ -9035,6 +9223,10 @@ ${deriveCapTile.toString()}
9035
9223
  el("span", { class: "ds-type", text: "Google Drive" }),
9036
9224
  dsNameEl(fname || id, "https://drive.google.com/drive/folders/" + id),
9037
9225
  ]),
9226
+ // 仮置きの行(#388)の印。Drive は行が ID 文字列なので drivePending から引く
9227
+ pendingBadgeEl(src.drivePending && src.drivePending[id] != null
9228
+ ? { pending: true, pendingAt: src.drivePending[id] }
9229
+ : null),
9038
9230
  ]);
9039
9231
  // **名前を出したときはIDも併記する。** 同名のフォルダを見分けられなくなるのと、
9040
9232
  // 「このIDを外す」という確認文と画面の表示が食い違うのを防ぐ
@@ -9177,6 +9369,8 @@ ${deriveCapTile.toString()}
9177
9369
  el("span", { class: "ds-type", text: "Figma" }),
9178
9370
  dsNameEl(f.name || f.key, "https://www.figma.com/design/" + f.key),
9179
9371
  ]),
9372
+ // 仮置きの行(#388)の印。無ければ null
9373
+ pendingBadgeEl(f),
9180
9374
  // このファイルの取り込みが最後にどうなったか(段C)。測っていなければ null
9181
9375
  ingestedEl(designIngestedStateOf(f.key), "デザイン"),
9182
9376
  ]);
@@ -9576,9 +9770,16 @@ ${deriveCapTile.toString()}
9576
9770
  endBusy();
9577
9771
  return;
9578
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;
9579
9780
  msg.className = "ds-toggle-msg is-ok";
9580
9781
  msg.textContent = "";
9581
- msg.appendChild(document.createTextNode("✓ " + (j.message || "保存しました。")));
9782
+ msg.appendChild(document.createTextNode("✓ " + ((pendingEntry ? noteHeadline(j.message) : j.message) || "保存しました。")));
9582
9783
  // **起動できたときだけ導線を出す。** 押した直後に「効いたか」を確かめられるのが要点。
9583
9784
  // 起動できなかった案件(権限・スタブ未配布)ではリンクを出さない——押しても何も無い
9584
9785
  if (j.runsUrl) {
@@ -9607,6 +9808,18 @@ ${deriveCapTile.toString()}
9607
9808
  //
9608
9809
  // **既存4呼び出しの onDone 意味論には触れない。** 使い分けは呼び出し側の責任で、
9609
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
+ }
9610
9823
  if (hooks && typeof hooks.onSaved === "function") hooks.onSaved(j);
9611
9824
  // **保存が効いたら、いまの登録を読み直す(段階1)。** 一覧はまとめ表(夜間バッチ)から
9612
9825
  // 描かれるので、保存しただけでは行が出ない。ここで読み直すと、登録ファイルの中身が
@@ -9621,7 +9834,8 @@ ${deriveCapTile.toString()}
9621
9834
  // 正しく反映できる。**流れるのは res.ok かつ changed !== false のときだけ**で、
9622
9835
  // removeButton 経由の onDone(引数を取らない)には届かない——受け側は
9623
9836
  // j が undefined でも動く形に保つこと(既存の4呼び出しは引数を取らない)
9624
- if (onDone && j.changed !== false) onDone(j);
9837
+ // 第2引数は仮置きの結果(#388)。控えを出すかの判断に使う(addForm)。既存の受け側は見ない
9838
+ if (onDone && j.changed !== false) onDone(j, { pending: pendingEntry });
9625
9839
  } catch (e) {
9626
9840
  msg.className = "ds-toggle-msg is-error";
9627
9841
  msg.textContent = "✗ 通信に失敗しました。";
@@ -9629,6 +9843,13 @@ ${deriveCapTile.toString()}
9629
9843
  }
9630
9844
  }
9631
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
+
9632
9853
  /**
9633
9854
  * 危険な方向にだけ出す確認。**何が起きるかを具体に書く**(「よろしいですか」だけにしない)。
9634
9855
  *
@@ -9896,12 +10117,15 @@ ${deriveCapTile.toString()}
9896
10117
  var added = main.value.trim();
9897
10118
  var addedName = name ? name.value.trim() : "";
9898
10119
  var payload = opts.build(added, addedName, wsSelect ? wsSelect.value : "");
9899
- sendConfig(payload, msg, btn, function () {
10120
+ sendConfig(payload, msg, btn, function (j, info) {
9900
10121
  // 追加できたら入力欄を空にする(同じものを二度押ししないように)
9901
10122
  main.value = "";
9902
10123
  if (name) name.value = "";
9903
10124
  // 応答で本物の一覧が描き直される口では、控えを出さない(同じものが2回並ぶ)
9904
10125
  if (opts.hooks) return;
10126
+ // 仮置き(#388)が作れたときも同じ——一覧に「反映待ち」の行が出ている。
10127
+ // 作れなかった(URL を切り出せなかった)ときだけ従来の控えに落ちる
10128
+ if (info && info.pending) return;
9905
10129
  // **追加したものをその場に出す。** 上の一覧は fleet-status.json(夜間スナップショット)
9906
10130
  // から描かれているので、追加してもその場では変わらない。何も変わらないと
9907
10131
  // 「追加できていない」と読まれ、もう一度押して「既に登録されています」になる
@@ -10029,7 +10253,9 @@ ${deriveCapTile.toString()}
10029
10253
  // 折りたたみの見出しでまとめるのをやめた(オーナー指摘「複雑になっちゃってる」)ので、
10030
10254
  // **どの組織かを言うのは行のラベルだけ**になった。社内に何も出さないと、
10031
10255
  // ラベルの無い行が「社内」なのか「まだ分かっていない」のかを読み分けられない。
10032
- if (s.type === "slack") {
10256
+ // **仮置きの行(#388)には出さない。** どの組織かはサーバが追加時に実測して書くが
10257
+ // 応答には載らないので、ここで「クラスメソッド」と名乗ると嘘になる(本物の行が来れば付く)
10258
+ if (s.type === "slack" && s.pending !== true) {
10033
10259
  // **順序は groupSlackByOrg と揃える(workspace が先)。**
10034
10260
  // 逆にすると、判定が済んで workspace が入った行に古い印が残っているとき、
10035
10261
  // **並びは「Hapio AI」なのにラベルだけ「組織が分かりません」**になる
@@ -10091,7 +10317,12 @@ ${deriveCapTile.toString()}
10091
10317
  //
10092
10318
  // ⚠ **s.origin 自体は残す。** 「登録から削除」を出すかどうかの判定に使っている
10093
10319
  //(自動検出の行に削除を出すと、押しても翌日戻ってくる)。**札を出さないだけ**
10094
- 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);
10095
10326
  // **「接続を確認」が押されたら、この要素を live の判定で置き換える**(probeSection)。
10096
10327
  // 2つの判定を同じ行に並べないため、参照を持っておく
10097
10328
  var gateEl = el("span", { class: "ds-gate " + gate.cls, text: gate.text });
@@ -10132,8 +10363,10 @@ ${deriveCapTile.toString()}
10132
10363
  if (SITE.intake && s.type === "slack" && s.ref && s.origin !== "explicit") {
10133
10364
  card.appendChild(removeChannelButton(s, card));
10134
10365
  // 通知先の入り切り。**解除と同じ条件で出す**——書き換える先は違う(notify.json)が、
10135
- // どちらも「チャット/channels.json に登録がある行」でしか効かない
10136
- card.appendChild(notifyToggle(s, badges));
10366
+ // どちらも「チャット/channels.json に登録がある行」でしか効かない。
10367
+ // 仮置きの行(#388)には出さない——ワークスペースが未確定で、顧客ワークスペースなら
10368
+ // 押しても断られる(本物の行が来てから)
10369
+ if (s.pending !== true) card.appendChild(notifyToggle(s, badges));
10137
10370
  }
10138
10371
  // Google Chat のスペースの登録解除(#153)。#155 のモデル(載っていること自体が
10139
10372
  // 読み取りの意思決定・止める手段は登録解除)に Slack と同型で揃える。
@@ -10204,10 +10437,12 @@ ${deriveCapTile.toString()}
10204
10437
  // 気づいた人がその場で確かめられるほうがよい(オーナー判断)。書き込みになる
10205
10438
  // 「表示名を更新する」は、押した結果の中から別のボタンとして出る(probeSection)。
10206
10439
  //
10207
- // **いまは Slack と 開発(GitHub)。** GitHub は #234 で足した——「アクセス不可 →
10440
+ // **いまは Slack・開発(GitHub)・Google Chat。** GitHub は #234 で足した——「アクセス不可 →
10208
10441
  // App の Configure でリポジトリを追加 → 直ったか確かめる」が、それまで**翌朝まで
10209
10442
  // 待つしかなかった**(受け入れテストでは fleet-status.yml を手で起動して確かめた)。
10210
10443
  // 判定と文面は infra/lambda/shared/github-gate.mjs 側(画面に分岐を持たせない)。
10444
+ // **Google Chat は #347(同じ理由)。** スペースにアプリを追加した人が、その場で
10445
+ // 直ったことを確かめられる。判定は infra/lambda/shared/google-chat-gate.mjs 側。
10211
10446
  // Backlog / Figma / Drive は実測そのものが新規なので、まだ出さない。
10212
10447
  //
10213
10448
  // **origin === "explicit" でも出す。** 確認は読むだけで、どの登録ファイル由来かに
@@ -10542,6 +10777,8 @@ ${deriveCapTile.toString()}
10542
10777
  // 捨てる(断らない)ので、画面と Lambda の版が食い違っても接続状況の表示は消えない
10543
10778
  // **保存直後だけ据え置きを迂回させる。** これが無いと、サーバが保存前の答えを返し、
10544
10779
  // 足りない行が0件になって取り直しも張られない=手で再読み込みするまで追いつかない
10780
+ // 仮置きの失効判定は「要求を出した時刻」で見る(LIVE_REGISTERED_AT の項)
10781
+ var startedAt = Date.now();
10545
10782
  callIntake(
10546
10783
  "state",
10547
10784
  useFresh ? { caps: ALL_STATE_CAPS, fresh: true } : { caps: ALL_STATE_CAPS },
@@ -10565,6 +10802,7 @@ ${deriveCapTile.toString()}
10565
10802
  // (=立ち上げ直後=この仕組みが一番効く案件)は下で throw するので、
10566
10803
  // ここより後に置くと**その案件だけ素通り**する
10567
10804
  LIVE_REGISTERED = sanitizeRegisteredNow(j && j.registeredNow);
10805
+ LIVE_REGISTERED_AT = startedAt;
10568
10806
  LIVE_STATE = sanitizeStateNow(j && j.stateNow);
10569
10807
  // **欄の有無をそのまま覚える。** sanitize を通した後では「読めて0件」が消える
10570
10808
  LIVE_STATE_SEEN = !!(j && Array.isArray(j.stateNow));
@@ -13618,6 +13856,36 @@ ${deriveCapTile.toString()}
13618
13856
  a.setAttribute("title", "案件一覧へ");
13619
13857
  a.classList.add("site-title-portal");
13620
13858
  })();
13859
+ /**
13860
+ * 外部の承認画面へ送り出したボタンを、戻ってきたときに押せる状態へ戻す(#343)。
13861
+ *
13862
+ * **live DOM を走査する。** 押した時点で登録簿に貯める形にすると、モーダルの
13863
+ * 開閉ごとに作り直されるボタンの、閉じた側の DOM を掴み続ける。
13864
+ * 戻す先は**素の初期状態**——「中断しました」のような文言は足さない
13865
+ *(戻ってきた人が知りたいのは「もう一度押せる」ことで、それは見た目で分かる)。
13866
+ */
13867
+ function restoreLeavingButtons() {
13868
+ var marked = document.querySelectorAll("[data-leaving]");
13869
+ for (var i = 0; i < marked.length; i++) {
13870
+ var btn = marked[i];
13871
+ btn.disabled = false;
13872
+ btn.removeAttribute("data-leaving");
13873
+ // 「準備しています…」を消すのは**同じ箱の中の**行だけ(他のボタンの結果を消さない)。
13874
+ // 箱が見つからなければボタンだけ戻す
13875
+ var wrap = btn.closest ? btn.closest(".ds-toggle") : null;
13876
+ var msg = wrap ? wrap.querySelector(".ds-toggle-msg") : null;
13877
+ if (msg) {
13878
+ msg.className = "ds-toggle-msg";
13879
+ msg.textContent = "";
13880
+ }
13881
+ }
13882
+ }
13883
+ // **bfcache から復元されたときだけ走る。** 通常のロード(persisted が false)では
13884
+ // DOM ごと作り直されるので、戻すものは無い
13885
+ window.addEventListener("pageshow", function (e) {
13886
+ if (!e.persisted) return;
13887
+ restoreLeavingButtons();
13888
+ });
13621
13889
  // ヘッダーの案件アイコン(#348)は #app の外なので、**起動時に1回だけ描く**。
13622
13890
  // route() はタブを替えるたびに走るが、ヘッダーは描き替えない——描き直すと
13623
13891
  // 保存直後の mirror とメニューの開閉が、タブを移るたびに畳まれる