@takagaki/cortex-decisions-viewer 0.12.134 → 0.12.138

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/build.js CHANGED
@@ -653,6 +653,10 @@ async function readFleetSources(repoRoot) {
653
653
  capabilities = out;
654
654
  }
655
655
  const fleetGeneratedAt = typeof obj.generatedAt === "string" ? obj.generatedAt : undefined;
656
+ // 画面は案内の宛先に出し、[コピー]の中身にもする。fleet-status.json は案件リポにコミットされる=人が書き換えうるので、メールの形だけ通す
657
+ const driveReaderEmail = typeof obj.driveReaderEmail === "string" && /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(obj.driveReaderEmail)
658
+ ? obj.driveReaderEmail
659
+ : undefined;
656
660
  // いま同期している Backlog の接続先(fleet-status.json トップレベル・#111)。
657
661
  //
658
662
  // **画面に現在値を出すために要る。** 「接続先を変更」フォームが入力欄だけだったため、
@@ -712,7 +716,7 @@ async function readFleetSources(repoRoot) {
712
716
  if (Object.keys(out).length)
713
717
  secretSpaces = out;
714
718
  }
715
- return { externalSources, slackWorkspaces, internalSources, pipelines, capabilities, fleetGeneratedAt, secretSources, secretSpaces, checks, githubOwners, backlogTarget, tools };
719
+ return { externalSources, slackWorkspaces, internalSources, pipelines, capabilities, fleetGeneratedAt, driveReaderEmail, secretSources, secretSpaces, checks, githubOwners, backlogTarget, tools };
716
720
  }
717
721
  /**
718
722
  * 区画のレコードを活性度・強さの順に並べる(用語集だけが動く・#665 便 12b → #1094 便 e4 で帯を外した)。
@@ -2651,6 +2655,7 @@ async function buildSite(opts, views) {
2651
2655
  externalSources: fleet.externalSources,
2652
2656
  slackWorkspaces: fleet.slackWorkspaces,
2653
2657
  fleetGeneratedAt: fleet.fleetGeneratedAt,
2658
+ driveReaderEmail: fleet.driveReaderEmail,
2654
2659
  secretSources: fleet.secretSources,
2655
2660
  secretSpaces: fleet.secretSpaces,
2656
2661
  internalSources: fleet.internalSources,
@@ -22,6 +22,7 @@ exports.PENDING_OPS_LIMITS = void 0;
22
22
  exports.pendingKindsTable = pendingKindsTable;
23
23
  exports.pendingOpsKey = pendingOpsKey;
24
24
  exports.pendingSlackChannelId = pendingSlackChannelId;
25
+ exports.pendingSlackUrl = pendingSlackUrl;
25
26
  exports.pendingIsSpaceRef = pendingIsSpaceRef;
26
27
  exports.pendingGoogleChatSpaceRef = pendingGoogleChatSpaceRef;
27
28
  exports.pendingRepo = pendingRepo;
@@ -34,6 +35,9 @@ exports.pendingLabel = pendingLabel;
34
35
  exports.pendingOpOf = pendingOpOf;
35
36
  exports.sanitizePendingOps = sanitizePendingOps;
36
37
  exports.rememberPendingOp = rememberPendingOp;
38
+ exports.pendingOpsOfTarget = pendingOpsOfTarget;
39
+ exports.rollbackPendingOp = rollbackPendingOp;
40
+ exports.settlePendingOp = settlePendingOp;
37
41
  exports.pendingSiteTimeMs = pendingSiteTimeMs;
38
42
  exports.pendingStale = pendingStale;
39
43
  exports.pendingOpBuildCaughtUp = pendingOpBuildCaughtUp;
@@ -112,6 +116,18 @@ function pendingSlackChannelId(input) {
112
116
  const client = s.match(/^https?:\/\/app\.slack\.com\/client\/[TE][A-Z0-9]{6,20}\/([CG][A-Z0-9]{6,})/);
113
117
  return client ? client[1] : null;
114
118
  }
119
+ /**
120
+ * Slack のチャンネル URL(リンク用)。貼られた URL が `<ドメイン>.slack.com/archives/<ID>` で、ID が
121
+ * 対象のチャンネルと同じならそれを正規化して返す。取れなければ `https://slack.com/archives/<ID>`
122
+ * (夜のまとめ=サーバの logic.mjs と同じ形)。**夜のまとめの `url` が来たらそちらが正**で、これは仮置き用。
123
+ */
124
+ function pendingSlackUrl(input, id) {
125
+ const cid = String(id == null ? "" : id);
126
+ const m = String(input == null ? "" : input).trim().match(/^https:\/\/([a-z0-9][a-z0-9-]*)\.slack\.com\/archives\/([CG][A-Z0-9]{6,})(?:[/?#]|$)/);
127
+ if (m && m[2] === cid && m[1] !== "app")
128
+ return "https://" + m[1] + ".slack.com/archives/" + cid;
129
+ return cid ? "https://slack.com/archives/" + cid : "";
130
+ }
115
131
  /**
116
132
  * スペース ref の形(サーバの `GOOGLE_CHAT_SPACE_REF` の写し)。ずれは parity テストが固定する。
117
133
  *
@@ -266,6 +282,9 @@ function pendingOpOf(payload, ctx) {
266
282
  case "add-channel": {
267
283
  const id = pendingSlackChannelId(payload.url);
268
284
  const data = { name: pendingLabel(payload.name) || id };
285
+ // 貼られた URL でリンクにする(追加直後・接続を確認する前から)。ドメインが取れなければ共通の形
286
+ if (id)
287
+ data.url = pendingSlackUrl(payload.url, id);
269
288
  if (payload.workspace)
270
289
  data.workspace = String(payload.workspace);
271
290
  // **どの組織のチャンネルかは、サーバの実測を応答から受け取る(#391)。**
@@ -283,7 +302,14 @@ function pendingOpOf(payload, ctx) {
283
302
  return make("slack", "remove", payload.ref);
284
303
  case "refresh-channel-name": {
285
304
  const name = pendingLabel(payload.name);
286
- return name ? make("slack", "update", payload.ref, { name: name }) : null;
305
+ if (!name)
306
+ return null;
307
+ // 接続を確認できた行は、チャンネルの URL も揃える(行に url が無いときだけ効く・合成の項)
308
+ const data = { name: name };
309
+ const url = pendingSlackUrl(payload.url, payload.ref);
310
+ if (url)
311
+ data.url = url;
312
+ return make("slack", "update", payload.ref, data);
287
313
  }
288
314
  case "set-notify": {
289
315
  if (typeof payload.value !== "boolean")
@@ -367,7 +393,7 @@ function sanitizePendingOps(raw, projectKey) {
367
393
  const src = e.payload && typeof e.payload === "object" ? e.payload : {};
368
394
  // 通してよい payload の欄(葉は文字列か、notify だけ真偽値)。関数の中に置くのは、
369
395
  // クライアントへ toString() で流し込んだ先でも同じ一覧を参照できるようにするため
370
- const fields = ["name", "workspace", "notify", "domain", "projectKey", "type", "slackOrg"];
396
+ const fields = ["name", "workspace", "notify", "domain", "projectKey", "type", "slackOrg", "url"];
371
397
  for (const f of fields) {
372
398
  if (!Object.prototype.hasOwnProperty.call(src, f))
373
399
  continue;
@@ -375,6 +401,11 @@ function sanitizePendingOps(raw, projectKey) {
375
401
  if (src[f] === true || src[f] === false)
376
402
  payload[f] = src[f];
377
403
  }
404
+ else if (f === "url") {
405
+ // href に入る値。localStorage から読み戻すので、Slack のチャンネル URL の形だけ通す
406
+ if (typeof src[f] === "string" && /^https:\/\/(?:[a-z0-9][a-z0-9-]*\.)?slack\.com\/archives\/[CG][A-Z0-9]{6,}$/.test(src[f]))
407
+ payload[f] = src[f];
408
+ }
378
409
  else if (typeof src[f] === "string")
379
410
  payload[f] = src[f];
380
411
  }
@@ -387,6 +418,8 @@ function sanitizePendingOps(raw, projectKey) {
387
418
  payload: payload,
388
419
  committedAt: e.committedAt,
389
420
  baseBuildId: typeof e.baseBuildId === "string" ? e.baseBuildId : "",
421
+ ...(typeof e.nonce === "string" && e.nonce && e.nonce.length <= 64 ? { nonce: e.nonce } : {}),
422
+ ...(e.inflight === true ? { inflight: true } : {}),
390
423
  });
391
424
  }
392
425
  out.sort(function (a, b) { return a.committedAt - b.committedAt; });
@@ -417,7 +450,7 @@ function rememberPendingOp(list, entry) {
417
450
  for (const k in entry.payload)
418
451
  if (Object.prototype.hasOwnProperty.call(entry.payload, k))
419
452
  merged[k] = entry.payload[k];
420
- return { v: 1, projectKey: e.projectKey, kind: e.kind, targetId: e.targetId, action: e.action, payload: merged, committedAt: e.committedAt, baseBuildId: e.baseBuildId };
453
+ return { v: 1, projectKey: e.projectKey, kind: e.kind, targetId: e.targetId, action: e.action, payload: merged, committedAt: e.committedAt, baseBuildId: e.baseBuildId, ...(e.nonce ? { nonce: e.nonce } : {}), ...(e.inflight === true ? { inflight: true } : {}) };
421
454
  });
422
455
  if (folded)
423
456
  return out;
@@ -436,13 +469,84 @@ function rememberPendingOp(list, entry) {
436
469
  if (Object.prototype.hasOwnProperty.call(entry.payload, k))
437
470
  merged[k] = entry.payload[k];
438
471
  const rest = cur.filter(function (e) { return !(same(e) && e.action === "update"); });
439
- rest.push({ v: 1, projectKey: entry.projectKey, kind: entry.kind, targetId: entry.targetId, action: entry.action, payload: merged, committedAt: entry.committedAt, baseBuildId: entry.baseBuildId });
472
+ rest.push({ v: 1, projectKey: entry.projectKey, kind: entry.kind, targetId: entry.targetId, action: entry.action, payload: merged, committedAt: entry.committedAt, baseBuildId: entry.baseBuildId, ...(entry.nonce ? { nonce: entry.nonce } : {}), ...(entry.inflight === true ? { inflight: true } : {}) });
440
473
  return rest;
441
474
  }
442
475
  const kept = cur.filter(function (e) { return !same(e); });
443
476
  kept.push(entry);
444
477
  return kept;
445
478
  }
479
+ // ---- 楽観更新の巻き戻し(cortex-engine #968 E)-------------------------------
480
+ /**
481
+ * 同じ (kind, targetId) の仮置きだけを抜き出す。送信時に仮置きを書く前の姿(`rollbackPendingOp` の戻し先)。
482
+ */
483
+ function pendingOpsOfTarget(list, entry) {
484
+ if (!entry)
485
+ return [];
486
+ return (list || []).filter(function (e) { return e.kind === entry.kind && e.targetId === entry.targetId; });
487
+ }
488
+ /**
489
+ * 送信時に書いた仮置き(`entry`)を**自分の nonce の行だけ**取り消す(他の送信中の仮置きは消さない)。
490
+ *
491
+ * - 自分の行が置き換えた送信前の行(`before`)は、その対象にいま別の行が無く、かつ失敗済みの送信の行(`failed` の nonce)でないときだけ戻す
492
+ * (失敗済みの行を復活させない・あとから来た別の操作の行を潰さない)。
493
+ * - update が add に畳まれていた(自分の nonce の行が無い)ときは、同じ nonce の行を送信前の行へ差し替える。
494
+ * @param failed すでに巻き戻した送信の nonce の一覧
495
+ */
496
+ function rollbackPendingOp(list, entry, before, failed) {
497
+ const cur = list || [];
498
+ if (!entry)
499
+ return cur.slice();
500
+ const dead = failed || [];
501
+ const nonce = entry.nonce;
502
+ const sameTarget = function (e) { return e.kind === entry.kind && e.targetId === entry.targetId; };
503
+ const back = (before || []).filter(function (b) { return sameTarget(b) && !(b.nonce && dead.indexOf(b.nonce) >= 0); });
504
+ let found = false;
505
+ let out = cur.filter(function (e) {
506
+ if (nonce && e.nonce === nonce) {
507
+ found = true;
508
+ return false;
509
+ }
510
+ return true;
511
+ });
512
+ if (!found && entry.action === "update") {
513
+ // 畳まれた update: add の行(同じ nonce の送信前の行があれば)を送信前へ戻す
514
+ out = out.map(function (e) {
515
+ if (!sameTarget(e) || !e.nonce)
516
+ return e;
517
+ for (let i = 0; i < back.length; i++)
518
+ if (back[i].nonce === e.nonce)
519
+ return back[i];
520
+ return e;
521
+ });
522
+ out.sort(function (a, b) { return a.committedAt - b.committedAt; });
523
+ return out;
524
+ }
525
+ if (!out.some(sameTarget))
526
+ out = out.concat(back);
527
+ out.sort(function (a, b) { return a.committedAt - b.committedAt; });
528
+ return out;
529
+ }
530
+ /**
531
+ * 応答が成功したとき、自分の nonce の行を「送信中」から「確定」へ(送信中の印を外し、サーバの応答で分かった欄を足す)。
532
+ * 自分の行がもう無い(あとの操作に置き換えられた)なら何もしない——**あとの操作の行を書き直さない**。
533
+ * @param final 応答つきで作り直した仮置き(`pendingOpOf` の結果)。無ければ印を外すだけ
534
+ */
535
+ function settlePendingOp(list, nonce, final) {
536
+ return (list || []).map(function (e) {
537
+ if (!nonce || e.nonce !== nonce)
538
+ return e;
539
+ const payload = {};
540
+ for (const k in e.payload)
541
+ if (Object.prototype.hasOwnProperty.call(e.payload, k))
542
+ payload[k] = e.payload[k];
543
+ if (final && final.payload)
544
+ for (const k in final.payload)
545
+ if (Object.prototype.hasOwnProperty.call(final.payload, k))
546
+ payload[k] = final.payload[k];
547
+ return { v: 1, projectKey: e.projectKey, kind: e.kind, targetId: e.targetId, action: e.action, payload: payload, committedAt: e.committedAt, baseBuildId: e.baseBuildId, nonce: e.nonce };
548
+ });
549
+ }
446
550
  // ---- 失効 ----------------------------------------------------------------
447
551
  /** SITE.generatedAt("2026-09-03 00:00 UTC" / ISO)→ ms。読めなければ NaN */
448
552
  function pendingSiteTimeMs(s) {
@@ -616,6 +720,9 @@ function prunePendingOps(list, ctx) {
616
720
  return false;
617
721
  if (now > e.committedAt + PENDING_OPS_TTL_MS)
618
722
  return false;
723
+ // 送信中のまま 2 分を過ぎた行(画面を閉じた・落ちた送信の置き土産)は捨てる(cortex-engine #968 E)
724
+ if (e.inflight === true && now > e.committedAt + 120000)
725
+ return false;
619
726
  if (pendingOpBuildCaughtUp(e, c.siteNowMs))
620
727
  return false;
621
728
  if (pendingOpLiveAgrees(e, c.live, c.liveAt))
@@ -656,6 +763,8 @@ function pendingExternalRow(e) {
656
763
  row.type = "slack";
657
764
  row.name = p.name || e.targetId;
658
765
  row.ref = e.targetId;
766
+ if (typeof p.url === "string" && p.url)
767
+ row.url = p.url;
659
768
  // channels.json に書いた行= derived(dev-sources 側の宣言ではない)
660
769
  row.origin = "derived";
661
770
  if (p.workspace)
@@ -780,6 +889,9 @@ function applyPendingOps(view, list) {
780
889
  const patch = {};
781
890
  if (e.kind === "slack" && typeof p.name === "string" && p.name)
782
891
  patch.name = p.name;
892
+ // 夜のまとめの url が正。無い行にだけ仮置きの url を足す
893
+ if (e.kind === "slack" && typeof p.url === "string" && p.url && !externals[idx].url)
894
+ patch.url = p.url;
783
895
  if (typeof p.notify === "boolean")
784
896
  patch.notify = p.notify;
785
897
  externals[idx] = Object.assign({}, externals[idx], patch);
package/dist/render.js CHANGED
@@ -6388,6 +6388,7 @@ var PENDING_OPS_SETTLE_MS = ${JSON.stringify(pending_ops_1.PENDING_OPS_LIMITS.se
6388
6388
  var PENDING_KINDS = ${JSON.stringify((0, pending_ops_1.pendingKindsTable)())};
6389
6389
  ${pending_ops_1.pendingOpsKey.toString()}
6390
6390
  ${pending_ops_1.pendingSlackChannelId.toString()}
6391
+ ${pending_ops_1.pendingSlackUrl.toString()}
6391
6392
  ${pending_ops_1.pendingGoogleChatSpaceRef.toString()}
6392
6393
  ${pending_ops_1.pendingRepo.toString()}
6393
6394
  ${pending_ops_1.pendingFigmaKey.toString()}
@@ -6398,6 +6399,9 @@ ${pending_ops_1.pendingIsSlackTeamId.toString()}
6398
6399
  ${pending_ops_1.pendingOpOf.toString()}
6399
6400
  ${pending_ops_1.sanitizePendingOps.toString()}
6400
6401
  ${pending_ops_1.rememberPendingOp.toString()}
6402
+ ${pending_ops_1.pendingOpsOfTarget.toString()}
6403
+ ${pending_ops_1.rollbackPendingOp.toString()}
6404
+ ${pending_ops_1.settlePendingOp.toString()}
6401
6405
  ${pending_ops_1.pendingSiteTimeMs.toString()}
6402
6406
  ${pending_ops_1.pendingStale.toString()}
6403
6407
  ${pending_ops_1.pendingOpBuildCaughtUp.toString()}
@@ -6649,11 +6653,14 @@ ${calendarAddUrl.toString()}
6649
6653
  * **誰を招待すればいいのかが画面から分からなかった**——担当者に聞かないと
6650
6654
  * 手順が完了できなかった。
6651
6655
  *
6652
- * **艦隊で1つ。** 案件ごとに違うものではないので定数で足りる
6653
- * (案件ごとになったら fleet-status から運ぶ器に変える)。
6656
+ * **艦隊で1つ。** 案件ごとに違うものではない。**アドレスはここに直書きしない**——
6657
+ * engine が運営の設定(config/operator.json の driveReaderEmail)から
6658
+ * fleet-status.json のトップに置いた値を読む(cortex-engine #1094 便 γ 5-14)。
6659
+ * 値が届いていない(古い生成物)ときは空にして、宛先を名指さない文に倒す
6660
+ * (他の組織の画面に別の会社のアドレスを出さない)。
6654
6661
  * 議事録の取り込みでも同じアカウントを使っている。
6655
6662
  */
6656
- var DRIVE_INGEST_ACCOUNT = "cortex-notetaker@classmethod.jp";
6663
+ var DRIVE_INGEST_ACCOUNT = typeof SITE.driveReaderEmail === "string" ? SITE.driveReaderEmail : "";
6657
6664
  // Google Chat の「スペースにアプリを追加する」手順の画像(#389)。理由は
6658
6665
  // src/google-chat-guide-images.ts に書いてある
6659
6666
  // Google Chat アプリの表示名。**Marketplace 公開名(2026-09-03 オーナー決定で「Cortex Bot」から
@@ -10034,10 +10041,9 @@ ${calendarAddUrl.toString()}
10034
10041
  customer: {
10035
10042
  text: "トークン未設定",
10036
10043
  cls: "ds-gate-muted",
10037
- // 🔴 **行は札だけ(#1328)。** 一手「この Slack を Cortex に接続してください。[接続する]」は、
10038
- // 同じワークスペースの組の先頭に 1 回だけ置く(slackConnectNote)。10 チャンネルが
10039
- // 同じ一手を 10 回言わない。主語は「この Slack」(承認画面はワークスペースを指定できない
10040
- // ——slackConnectInline の項)
10044
+ // 一手「このワークスペースに Cortex が入っていません。[ワークスペースを接続する]」は、
10045
+ // 行のタイルの中が持つ(slackRowHelpParts・g-66)。承認画面はワークスペースを指定できない
10046
+ // ——slackConnectInline の項
10041
10047
  },
10042
10048
  /* -------------------------------------------------------------------------
10043
10049
  * GitHub。**「トークン未設定」と言ってはいけない**(2026-08-21)
@@ -10205,31 +10211,32 @@ ${calendarAddUrl.toString()}
10205
10211
  return [say, pick ? { link: "インストール設定を開く →", kind: "settings", slug: raw } : { link: "GitHub App を入れる →", kind: "install", slug: raw }];
10206
10212
  }
10207
10213
  /**
10208
- * Slack の「理由不明」の行(unreachable・組織不明の仮置き)に出す一手(c-07)。
10209
- * **広い方の 1 行**にする——原因は行の分類では絞れない(0.12.2 の実測)ので、片方に賭けない。
10214
+ * Slack の「見えない」行(unreachable・no_token・組織不明の仮置き)に出す一手(c-07・g-66)。
10215
+ * **行のタイルの中**に出す(組の先頭=タイルの外には出さない)。症状は帯が言い、行は一手だけ。
10210
10216
  *
10211
- * - ワークスペースが分かっていて、台帳の接続が切れている/未接続 → 行には何も出さない(空の配列)。
10212
- * 一手は組の先頭の slackConnectNote が 1 回だけ言う
10213
- * - ワークスペースが分かっていて、接続が生きている → 招待だけ
10214
- * - 社内・組織不明(分類が当てにならない)→ 招待だけ(「お客様の Slack なら接続」は組の先頭が言う)
10217
+ * - 接続で直ると確かな行(ワークスペースが分かっていて台帳の接続が切れている/未接続、または no_token)
10218
+ * → 1 行「このワークスペースに Cortex が入っていません。[ワークスペースを接続する]」
10219
+ * - 招待で直るか接続で直るか見分けられない行(社内・組織不明の unreachable・仮置き)
10220
+ * → 1 行目 招待(/invite @Cortex[コピー])、2 行目「…入っていなければ、ワークスペースを接続してください。[接続する]」
10221
+ * - 接続が生きているワークスペース → 招待だけ
10215
10222
  *
10216
- * 🔴 **接続は行の一手に置かない(#1328)。** 同じワークスペースの全チャンネルが同じ一文を
10217
- * 繰り返していた。**行は札と、その行だけの一手(招待)だけ**。
10218
- * 🔴 **空の配列を返す(null にしない)。** null を返すと呼び側が夜間の理由文(機械の語)を代わりに出す。
10219
- * 投入の口(SITE.intake)が無い案件は接続を出せないので、従来どおり招待だけを返す。
10223
+ * 🔴 同じ行の中で同じ説明を 2 回言わない。投入の口(SITE.intake)が無い案件は接続を出せないので、招待だけ。
10224
+ * 🔴 **空の配列は返さない**(null も)。null を返すと呼び側が夜間の理由文(機械の語)を代わりに出す。
10220
10225
  */
10221
10226
  function slackRowHelpParts(row) {
10222
10227
  if (!row) return null;
10223
- var invite = ["Cortex からこのチャンネルが見えません。Cortex を招待してください:", { copy: "/invite @Cortex" }];
10224
- if (row.workspace) {
10225
- var st = tokenStateOf(slackWorkspaces(), row.workspace);
10226
- if (st === "revoked" || st === "missing") return SITE.intake ? [] : invite;
10227
- }
10228
- return invite;
10228
+ var invite = ["Cortex からこのチャンネルが見えません。チャンネルで Cortex を招待してください:", { copy: "/invite @Cortex" }];
10229
+ if (!slackRowNeedsConnect(row)) return invite;
10230
+ if (slackRowConnectSure(row)) return ["このワークスペースに Cortex が入っていません。", { act: "connect", label: "ワークスペースを接続する" }];
10231
+ return invite.concat([{ br: true }, "このワークスペースに Cortex が入っていなければ、ワークスペースを接続してください。", { act: "connect" }]);
10232
+ }
10233
+ /** 接続で直ると確かな行か(no_token、またはワークスペースが分かっていて台帳の接続が切れている/未接続) */
10234
+ function slackRowConnectSure(s) {
10235
+ return s.gate === "no_token" || !!s.workspace;
10229
10236
  }
10230
10237
  /**
10231
10238
  * この Slack の行が、ワークスペースの接続([接続する])で直る行か(#1328)。
10232
- * 組の先頭に一手を置くかどうかの判断だけに使う。**行には出さない。**
10239
+ * 行の一手に[接続する]を出すかどうかの判断に使う。
10233
10240
  *
10234
10241
  * - 顧客の no_token(組織不明の行を含む。社内の no_token は画面から直せないので含めない・2026-08-18)
10235
10242
  * - まだ測っていない組織不明の仮置きの行
@@ -10245,21 +10252,6 @@ ${calendarAddUrl.toString()}
10245
10252
  var st = tokenStateOf(slackWorkspaces(), s.workspace);
10246
10253
  return st === "revoked" || st === "missing";
10247
10254
  }
10248
- /**
10249
- * ワークスペースごとの組の先頭に置く、接続の一手(0 または 1 個の段落)。
10250
- * 「この Slack を Cortex に接続してください。[接続する]」(その組が顧客のワークスペースだと分かっているとき)か、
10251
- * 「お客様の Slack なら、ワークスペースを接続してください。[接続する]」(社内・組織不明の組)。
10252
- * **行の中の ds-gate-help ではない**(行の補足は最大 1 段・押した結果が行の補足を隠す仕掛けに巻き込まない)。
10253
- * @param g groupSlackByOrg の 1 組
10254
- */
10255
- function slackConnectNote(g) {
10256
- var needy = g.items.filter(slackRowNeedsConnect);
10257
- if (!needy.length) return null;
10258
- var sure = (g.key !== "__internal__" && g.key !== "__unknown__")
10259
- || needy.every(function (s) { return s.gate === "no_token"; });
10260
- var say = sure ? "この Slack を Cortex に接続してください。" : "お客様の Slack なら、ワークスペースを接続してください。";
10261
- return restingHelpNode([say, { act: "connect" }], "ds-group-note ds-ws-connect");
10262
- }
10263
10255
  /** 押した結果に添えるパーツ(無ければ null)。行と同じ表から引く(写経しない) */
10264
10256
  function probedHelpPartsOf(type, gate) {
10265
10257
  var e = restingHelpEntryOf(type, gate);
@@ -10268,13 +10260,14 @@ ${calendarAddUrl.toString()}
10268
10260
  /**
10269
10261
  * パーツを1段落にする。包む要素の class は置き場所で違う(行は ds-gate-help・押した結果は ds-probe-help)。
10270
10262
  * 部品: 文字列=テキスト / { link, kind } = GitHub App のリンク /
10271
- * { copy } =コピー付きの 1 語(文字列と[コピー]) / { act } =ボタン(actNode)。
10263
+ * { copy } =コピー付きの 1 語(文字列と[コピー]) / { act } =ボタン(actNode) / { br } =同じ段落の中の改行。
10272
10264
  * **部品は同じ段落の中に置く**(段を増やさない=行の補足は最大 1 段)。
10273
10265
  */
10274
10266
  function restingHelpNode(parts, cls) {
10275
10267
  var p = el("p", { class: cls });
10276
10268
  parts.forEach(function (part) {
10277
10269
  if (typeof part === "string") p.appendChild(document.createTextNode(part));
10270
+ else if (part && part.br) p.appendChild(el("br"));
10278
10271
  else if (part && part.link) p.appendChild(el("a", { href: githubAppLinkUrl(part.kind, part.slug), target: "_blank", rel: "noopener", text: part.link }));
10279
10272
  // bare … 文面に値が含まれているので[コピー]だけを出す(固定 IP の依頼文)
10280
10273
  else if (part && part.copy) p.appendChild(copyInline(part.copy, !part.bare));
@@ -10457,9 +10450,11 @@ ${calendarAddUrl.toString()}
10457
10450
  unreachable: {
10458
10451
  text: "フォルダを開けません",
10459
10452
  cls: "ds-gate-warn",
10460
- // 🔴 **一手は「このフォルダを cortex-notetaker@… に閲覧者として共有してください。[コピー]」の 1 行(c-07)。**
10453
+ // 🔴 **一手は「このフォルダを〈取り込み用アカウント〉に閲覧者として共有してください。[コピー]」の 1 行(c-07)。**
10461
10454
  // コピーは追加の欄の手順 1 にだけあったので、ここへ移した(help の文面にアドレスが含まれるので[コピー]だけ)
10462
- help: "このフォルダを " + DRIVE_INGEST_ACCOUNT + " に閲覧者として共有してください。",
10455
+ help: DRIVE_INGEST_ACCOUNT
10456
+ ? "このフォルダを " + DRIVE_INGEST_ACCOUNT + " に閲覧者として共有してください。"
10457
+ : "このフォルダを取り込み用のアカウントに閲覧者として共有してください(アカウントは管理者に確かめてください)。",
10463
10458
  copy: DRIVE_INGEST_ACCOUNT,
10464
10459
  },
10465
10460
  no_token: { text: "管理者の設定が必要です", cls: "ds-gate-muted" },
@@ -11368,11 +11363,7 @@ ${calendarAddUrl.toString()}
11368
11363
  out.push(resolved);
11369
11364
  }
11370
11365
  // 🔴 **接続が切れた/未接続のワークスペースの枠(brokenOrgNodes)は撤去した(c-07)。**
11371
- // 一手「この Slack を Cortex に接続してください。[接続する]」は、そのワークスペースの組の先頭に
11372
- // 1 回だけ置く(slackConnectNote・#1328)。行は札と、その行だけの一手(招待)だけを持つ。
11373
- // 入口を失わないための約束は決定メモに書いた
11374
- var connectNote = slackConnectNote(g);
11375
- if (connectNote) out.push(connectNote);
11366
+ // 接続の一手は、見えない行のタイルの中に行ごとに置く(slackRowHelpParts・g-66。組の先頭=タイルの外には出さない)
11376
11367
  cards.forEach(function (n) { out.push(n); });
11377
11368
  });
11378
11369
  return out;
@@ -11654,6 +11645,67 @@ ${calendarAddUrl.toString()}
11654
11645
  MERGED = null;
11655
11646
  return entry;
11656
11647
  }
11648
+ /**
11649
+ * 送信時の仮置き(cortex-engine #968 E・本当の楽観更新)。**サーバの答えを待たずに**仮置きを書き、
11650
+ * 戻し先(送信前のその対象の仮置き)を控えて返す。作れなければ null(従来どおり応答後に任せる)。
11651
+ * 応答が来たら notePendingOp(…, 応答) で同じ対象を書き直す(サーバが実測した組織などを足す)。
11652
+ */
11653
+ var OPT_SEQ = 0;
11654
+ /** 巻き戻した送信の nonce(失敗済みの行を、あとの巻き戻しで復活させないための控え) */
11655
+ var FAILED_NONCES = [];
11656
+ function noteOptimisticOp(payload, targets) {
11657
+ if (!SITE.intake) return null;
11658
+ PENDING_OPS = readPendingOps();
11659
+ var probe = pendingOpOf(payload, {
11660
+ projectKey: SITE.intake.projectKey,
11661
+ now: Date.now(),
11662
+ generatedAt: rawFleetVal("generatedAt") || "",
11663
+ targets: targets || null,
11664
+ response: null,
11665
+ });
11666
+ if (!probe) return null;
11667
+ var before = pendingOpsOfTarget(pendingOpsNow(), probe);
11668
+ // 送信ごとの一意 ID と送信中の印を付けて書く(巻き戻しは自分の行だけを外す)
11669
+ OPT_SEQ += 1;
11670
+ var nonce = "n" + Date.now().toString(36) + "-" + OPT_SEQ + "-" + Math.floor(Math.random() * 1e6).toString(36);
11671
+ probe.nonce = nonce;
11672
+ probe.inflight = true;
11673
+ PENDING_OPS = rememberPendingOp(pendingOpsNow(), probe);
11674
+ writePendingOps(PENDING_OPS);
11675
+ MERGED = null;
11676
+ return { entry: probe, before: before };
11677
+ }
11678
+ /**
11679
+ * 送信時の仮置きを巻き戻す(断られた・通信が落ちた・何も変わらなかった)。**自分の nonce の行だけ**外し、
11680
+ * 他の送信中の仮置きには触らない。巻き戻したあとは、開いている一覧を描き直して元の姿に戻す。
11681
+ */
11682
+ function rollbackOptimisticOp(opt) {
11683
+ if (!opt || !SITE.intake) return;
11684
+ PENDING_OPS = rollbackPendingOp(readPendingOps(), opt.entry, opt.before, FAILED_NONCES);
11685
+ if (opt.entry.nonce) FAILED_NONCES.push(opt.entry.nonce);
11686
+ writePendingOps(PENDING_OPS);
11687
+ MERGED = null;
11688
+ repaintOpenLists();
11689
+ refreshDataSources();
11690
+ syncAiLinks();
11691
+ }
11692
+ /**
11693
+ * 応答が成功したとき、自分の行を確定させる(送信中の印を外し、サーバが実測した欄を足す)。
11694
+ * 自分の行がもう無い(あとの操作に置き換えられた)なら何もしない。
11695
+ */
11696
+ function settleOptimisticOp(opt, payload, targets, response) {
11697
+ if (!opt || !SITE.intake) return;
11698
+ var final = pendingOpOf(payload, {
11699
+ projectKey: SITE.intake.projectKey,
11700
+ now: Date.now(),
11701
+ generatedAt: rawFleetVal("generatedAt") || "",
11702
+ targets: targets || null,
11703
+ response: response || null,
11704
+ });
11705
+ PENDING_OPS = settlePendingOp(readPendingOps(), opt.entry.nonce, final);
11706
+ writePendingOps(PENDING_OPS);
11707
+ MERGED = null;
11708
+ }
11657
11709
  /** 仮置きの行の印(「反映待ち」/「反映を確認できていません」)。印の無い行は null */
11658
11710
  function pendingBadgeEl(row) {
11659
11711
  var note = pendingRowNote(row, Date.now());
@@ -14136,7 +14188,7 @@ ${calendarAddUrl.toString()}
14136
14188
  callIntake("config", payload).then(function (res) {
14137
14189
  if (!res.ok) return;
14138
14190
  // 仮置き(#388): リロードしても新しい名前で出る。送っていない name を判定用に添える
14139
- notePendingOp({ op: "refresh-channel-name", ref: s.ref, name: r.name });
14191
+ notePendingOp({ op: "refresh-channel-name", ref: s.ref, name: r.name, url: s.url || "" });
14140
14192
  // 一覧は夜間のスナップショットなので、その場では変わらない。行だけ追いつかせる
14141
14193
  var nameEl = card.querySelector(".ds-name");
14142
14194
  if (nameEl) nameEl.textContent = slackDisplayName(r.name);
@@ -14383,16 +14435,25 @@ ${calendarAddUrl.toString()}
14383
14435
  var next = !on;
14384
14436
  // **ワークスペースは送らない。** この分岐に来るのは宣言の無い行だけで、
14385
14437
  // サーバも ON では受け取らない(送れば断られる)
14386
- await sendConfig({ op: "set-notify", ref: s.ref, value: next }, msg, btn, function () {
14387
- on = next;
14438
+ // 行の見た目を「いまの向き」へ揃える。**送信した瞬間に呼ぶ(楽観更新・#968 E)**・成功でも呼ぶ(同じ値で冪等)・
14439
+ // 断られたら元の向きで呼んで戻す
14440
+ var apply = function (v) {
14441
+ on = v;
14388
14442
  // 行の元データも合わせる。ライブの読み直しで同じ行が描き直されるまでの間、
14389
14443
  // ここだけが「いまどちらか」を持っている
14390
- s.notify = next;
14444
+ s.notify = v;
14391
14445
  btn.textContent = labelOf();
14392
14446
  var badge = badges.querySelector(".ds-notify");
14393
14447
  // 並びは描画時と同じ(札の先頭)。後ろに足すと、押した行だけ順番が変わる
14394
14448
  if (on && !badge) badges.insertBefore(notifyBadge(), badges.firstChild);
14395
14449
  if (!on && badge) badges.removeChild(badge);
14450
+ };
14451
+ var before = on;
14452
+ await sendConfig({ op: "set-notify", ref: s.ref, value: next }, msg, btn, function () {
14453
+ apply(next);
14454
+ }, {
14455
+ onOptimistic: function () { apply(next); },
14456
+ onRevert: function () { apply(before); },
14396
14457
  });
14397
14458
  });
14398
14459
  wrap.appendChild(btn);
@@ -15812,6 +15873,46 @@ ${calendarAddUrl.toString()}
15812
15873
  staleNote: TARGETS_RACE_NOTE,
15813
15874
  // 仮置き(#388)の鍵にドメインを入れるため、表示中の一覧を渡す(解除の payload にはキーしか無い)
15814
15875
  pendingTargets: function () { return targets; },
15876
+ // 送信した瞬間に一覧を先に直す(応答の一覧が来れば、それが正本として上書きする)。
15877
+ // 返す値は送信ごとの控え(連続操作でも他の送信の控えと混ざらない)
15878
+ onOptimistic: function (sent) {
15879
+ var op = sent && sent.op;
15880
+ var next = null;
15881
+ if (op === "backlog-target-remove") {
15882
+ var key = String(sent.backlogProjectKey || "").toUpperCase();
15883
+ var dropped = false;
15884
+ next = targets.filter(function (t) {
15885
+ if (!dropped && String(t.projectKey || "").toUpperCase() === key) { dropped = true; return false; }
15886
+ return true;
15887
+ });
15888
+ if (!dropped) next = null;
15889
+ } else if (op === "backlog-target-add") {
15890
+ var tg = backlogTargetOf(sent.url);
15891
+ if (tg) {
15892
+ var exists = targets.some(function (t) {
15893
+ return String(t.domain || "").toLowerCase() === String(tg.domain || "").toLowerCase() && String(t.projectKey || "").toUpperCase() === String(tg.projectKey || "").toUpperCase();
15894
+ });
15895
+ if (!exists) next = targets.concat([{ domain: String(tg.domain), projectKey: String(tg.projectKey) }]);
15896
+ }
15897
+ }
15898
+ if (!next) return undefined;
15899
+ var tok = { before: targets, after: targetsGenerationOf(next) };
15900
+ targets = next;
15901
+ ensureKeySlots();
15902
+ paint();
15903
+ return tok;
15904
+ },
15905
+ // 断られた・通信が落ちた・何も変わらなかった: 他の操作が一覧を変えていなければ送信前へ戻し、変えていれば
15906
+ // 正本を読み直す(連続操作で 1 つの控えが他の送信を潰さない)
15907
+ onRevert: function (tok) {
15908
+ if (!tok) return;
15909
+ if (tok.after === targetsGenerationOf(targets)) {
15910
+ targets = tok.before;
15911
+ paint();
15912
+ } else {
15913
+ try { loadLiveFleet(true); } catch (e) {}
15914
+ }
15915
+ },
15815
15916
  };
15816
15917
  }
15817
15918
 
@@ -16229,7 +16330,7 @@ ${calendarAddUrl.toString()}
16229
16330
  requiredNote: "フォルダのURLを入力してください。",
16230
16331
  // 🔴 **追加の欄は入力と[追加]だけ(c-07)。** 以前は「アカウントを招待 → URL を貼る → 何が起きるか」の
16231
16332
  // 手順(コピー行つき)を置いていたが、足してから読めなければ、その行の中が一手を出す
16232
- // (DS_GATE_CAP.materials.unreachable: 「このフォルダを cortex-notetaker@… に閲覧者として共有してください。[コピー]」)。
16333
+ // (DS_GATE_CAP.materials.unreachable: 「このフォルダを〈取り込み用アカウント〉に閲覧者として共有してください。[コピー]」)。
16233
16334
  // 「中身が 共有資料 にコピーされ Markdown 化される・止め方」はモーダル上部の説明文が 1 回だけ持つ。
16234
16335
  build: function (url, name) { return { op: "add-drive-folder", url: url, name: name }; },
16235
16336
  }));
@@ -16448,7 +16549,7 @@ ${calendarAddUrl.toString()}
16448
16549
  requiredNote: "チャンネルのURLを入力してください。",
16449
16550
  // 🔴 **追加の欄は入力と[追加]だけ(c-07)。** 以前は「/invite を貼る → リンクを貼る → 組織不明なら接続 →
16450
16551
  // Gold 昇格の対象」の手順 4 行を置いていたが、足してから読めなければ、そのチャンネルの行の中が
16451
- // 理由に合った一手(「Cortex を招待してください:/invite @Cortex[コピー]」。接続の一手は組の先頭(slackConnectNote))を出す。
16552
+ // 理由に合った一手(招待と接続。slackRowHelpParts)を出す。
16452
16553
  // 「毎晩の Gold 昇格の対象・止め方」「お客様の Slack は管理者の承認が要る」は、モーダル上部の説明文が 1 回だけ持つ。
16453
16554
  // **選んだワークスペースを送る(#104)。** 空文字=社内ワークスペースなのでキーごと落とす
16454
16555
  // (サーバ側は「未宣言・null・空」を区別しない約束で、無いことで既定を表す)
@@ -16574,6 +16675,22 @@ ${calendarAddUrl.toString()}
16574
16675
  };
16575
16676
  }
16576
16677
 
16678
+ /**
16679
+ * 二度押しだけを止める(語もスピナーも出さない)。**楽観更新の回の送信中**に使う(#968 E)——
16680
+ * 画面はもう先へ進んでいるので、「処理中…」で待たされていると読ませない。
16681
+ * @returns 元へ戻す関数。何度呼んでも 1 回しか効かない
16682
+ */
16683
+ function lockQuietly(btn) {
16684
+ if (!btn) return function () {};
16685
+ btn.disabled = true;
16686
+ var used = false;
16687
+ return function unlock() {
16688
+ if (used) return;
16689
+ used = true;
16690
+ btn.disabled = false;
16691
+ };
16692
+ }
16693
+
16577
16694
  /**
16578
16695
  * 反映待ちのオーバーレイ+スピナー(#378・共通部品)。
16579
16696
  *
@@ -16624,23 +16741,55 @@ ${calendarAddUrl.toString()}
16624
16741
  * **例外が接続先の一覧(#127)。** 追加・削除の応答にはいまの一覧が必ず
16625
16742
  * 添えられていて(成功も、重複で断られたときも、世代がずれていたときも)、これは
16626
16743
  * スナップショットではなく**サーバが書いた直後の確定値**なので、その場で描き直してよい。
16627
- * @param hooks { onTargets(list) → 画面が見ていた集合と違ったか, staleNote }
16744
+ * @param hooks { onTargets(list) → 画面が見ていた集合と違ったか, staleNote, onOptimistic(), onRevert() }
16628
16745
  * onTargets … 応答に添えられた一覧を画面へ取り込む。**成否を問わず先に呼ぶ**——
16629
16746
  * 断られたときこそ、画面を正しい状態へ追いつかせないと堂々巡りになる
16747
+ * onOptimistic … 送信した瞬間に呼ぶ(本当の楽観更新・#968 E)。呼び手が自分で直す画面(通知先の札・入力欄)を先に更新する
16748
+ * onRevert … 断られた・通信が落ちた・何も変わらなかったときに呼ぶ。onOptimistic で直したものを元へ戻す
16749
+ *
16750
+ * ## 楽観更新(cortex-engine #968 E)
16751
+ * 仮置き(notePendingOp)を**送信時**に書き、待ち表示(「保存中…」・「処理中…」)を出さずに一覧を先に更新する。
16752
+ * 失敗(エラー応答・通信失敗・何も変わらなかった)では仮置きを**巻き戻して**理由を出す。
16753
+ * 仮置きを作れない操作(切り出せない入力・仮置きの対象でない op)は、従来どおり待ち表示を出して応答を待つ。
16630
16754
  */
16631
16755
  async function sendConfig(payload, msg, btn, onDone, hooks) {
16632
16756
  var intake = SITE.intake;
16633
16757
  if (!intake) return;
16634
- // 押している間はボタンごと「処理中…」にする(#222・beginBusy の項)
16635
- var endBusy = beginBusy(btn);
16758
+ // 送信時の仮置き。作れたときは待ち表示を出さず、一覧を先に更新する
16759
+ var opt = noteOptimisticOp(payload, hooks && typeof hooks.pendingTargets === "function" ? hooks.pendingTargets() : null);
16760
+ // 押している間はボタンごと「処理中…」にする(#222・beginBusy の項)。**楽観更新の回は語を変えない**
16761
+ // (二度押しだけ止める。待たされていると読ませない)
16762
+ var endBusy = opt ? lockQuietly(btn) : beginBusy(btn);
16636
16763
  msg.className = "ds-toggle-msg";
16637
- msg.textContent = "保存中…";
16764
+ msg.textContent = opt ? "" : "保存中…";
16765
+ // 巻き戻す口。**何度呼んでも 1 回しか効かない**(失敗の分岐が複数あるため)
16766
+ var reverted = false;
16767
+ var optToken;
16768
+ var revert = function () {
16769
+ if (reverted) return;
16770
+ reverted = true;
16771
+ if (opt) rollbackOptimisticOp(opt);
16772
+ if (hooks && typeof hooks.onRevert === "function") hooks.onRevert(optToken);
16773
+ };
16774
+ if (opt && opt.entry.action !== "update") {
16775
+ // 追加・解除は、開いている一覧を今すぐ描き直す(応答後の描き直しと同じ 3 つ)
16776
+ repaintOpenLists();
16777
+ refreshDataSources();
16778
+ syncAiLinks();
16779
+ }
16780
+ if (hooks && typeof hooks.onOptimistic === "function") optToken = hooks.onOptimistic(payload);
16781
+ // 応答が届いたか。届いたあとの描画の例外で「保存できなかった」と巻き戻さないための印
16782
+ var answered = false;
16638
16783
  try {
16639
16784
  var res = await callIntake("config", payload);
16785
+ answered = true;
16640
16786
  var j = await res.json().catch(function () { return {}; });
16641
16787
  // 一覧が添えられていれば、断られていても先に画面を追いつかせる(#127)
16642
16788
  var raced = hooks && hooks.onTargets && Array.isArray(j.targets) ? hooks.onTargets(j.targets) : false;
16643
16789
  if (!res.ok) {
16790
+ // **送信時の仮置きを巻き戻す(#968 E)。** 先に戻す——重複の確認の画面は入力欄の値を読むし、
16791
+ // 「既に登録されています」の分岐は下で仮置きを書き直す
16792
+ revert();
16644
16793
  // **確認すれば覆せる断り方(2026-08-20・Slack の重複だけ)。**
16645
16794
  //
16646
16795
  // 全体定例のように複数案件に関係するチャンネルは実在するのに、
@@ -16728,9 +16877,19 @@ ${calendarAddUrl.toString()}
16728
16877
  // する——続きの「一覧への反映には数分かかります(再読み込みで最新になります)」は、
16729
16878
  // その場で「反映待ち」の行が出ている横では嘘(intake の文面は本便では触らない)。
16730
16879
  // **changed:false では書かない**(何も変わっていないのに追加したと見せない)
16731
- var pendingEntry = j.changed !== false
16732
- ? notePendingOp(payload, hooks && typeof hooks.pendingTargets === "function" ? hooks.pendingTargets() : null, j)
16733
- : null;
16880
+ // **何も変わらなかった回は、送信時の仮置きを巻き戻す**(変わっていないのに追加・解除したと見せない・#968 E)
16881
+ if (j.changed === false) revert();
16882
+ var pendingEntry = null;
16883
+ if (j.changed !== false) {
16884
+ var ptargets = hooks && typeof hooks.pendingTargets === "function" ? hooks.pendingTargets() : null;
16885
+ if (opt) {
16886
+ // 送信時に書いた自分の行を確定させる(あとの操作に置き換えられていれば何もしない)
16887
+ settleOptimisticOp(opt, payload, ptargets, j);
16888
+ pendingEntry = opt.entry;
16889
+ } else {
16890
+ pendingEntry = notePendingOp(payload, ptargets, j);
16891
+ }
16892
+ }
16734
16893
  msg.className = "ds-toggle-msg is-ok";
16735
16894
  msg.textContent = "";
16736
16895
  msg.appendChild(document.createTextNode("✓ " + ((pendingEntry ? noteHeadline(j.message) : j.message) || "保存しました。")));
@@ -16738,7 +16897,8 @@ ${calendarAddUrl.toString()}
16738
16897
  // 起動できなかった案件(権限・スタブ未配布)ではリンクを出さない——押しても何も無い
16739
16898
  if (j.runsUrl) {
16740
16899
  msg.appendChild(document.createTextNode(" "));
16741
- msg.appendChild(el("a", { href: j.runUrl || j.runsUrl, target: "_blank", rel: "noopener", title: "GitHub Actions の実行ログを開きます(プロジェクトリポジトリへのアクセスが要ります)", text: "実行状況を見る →" })); // その run を特定できていればそれ、無ければ一覧
16900
+ // **同期の一覧へのリンク**(run そのものは特定しない——応答は起動を待たない・#968 D)。起動できたかはその一覧で見る
16901
+ msg.appendChild(el("a", { href: j.runsUrl, target: "_blank", rel: "noopener", title: "GitHub Actions の同期の一覧を開きます(プロジェクトリポジトリへのアクセスが要ります)", text: "同期の一覧を見る →" }));
16742
16902
  }
16743
16903
  // **「反映の状況 →」のリンクは無くなった(#221・2026-08-25)。** 指していたのは
16744
16904
  // 「画面を追いつかせるための fleet-status の実行」で、その起動ごと撤去した
@@ -16792,9 +16952,13 @@ ${calendarAddUrl.toString()}
16792
16952
  // 第2引数は仮置きの結果(#388)。控えを出すかの判断に使う(addForm)。既存の受け側は見ない
16793
16953
  if (onDone && j.changed !== false) onDone(j, { pending: pendingEntry });
16794
16954
  } catch (e) {
16955
+ // 通信の失敗: 送信時の仮置きを巻き戻して理由を出す(#968 E)。サーバに届いていた可能性があるので、
16956
+ // 本物の登録を読み直す(保存できていれば行は live から出る)
16957
+ if (!answered) revert();
16795
16958
  msg.className = "ds-toggle-msg is-error";
16796
16959
  msg.textContent = "✗ 通信に失敗しました。";
16797
16960
  endBusy();
16961
+ if (opt && !answered) { try { loadLiveFleet(true); } catch (e2) {} }
16798
16962
  }
16799
16963
  }
16800
16964
 
@@ -17100,8 +17264,26 @@ ${calendarAddUrl.toString()}
17100
17264
  // 逆に接続先の一覧(Backlog)は onTargets を持つが確認は要らない——
17101
17265
  // 共通に足すと、あちらの「他の人が先に変えていた」の判定と混ざる。
17102
17266
  var h = opts.hooks ? opts.hooks() : null;
17103
- if (!opts.duplicateConfirmLines) return h;
17267
+ // **送信した瞬間に入力欄を空にし(楽観更新・#968 E)、断られたら元の値へ戻す**(打ち直させない)
17268
+ var sentMain = "";
17269
+ var sentName = "";
17104
17270
  h = h || {};
17271
+ // 呼び手の hooks(接続先の一覧の楽観更新など)が既にあれば、その後ろに続ける
17272
+ var baseOptimistic = h.onOptimistic;
17273
+ var baseRevert = h.onRevert;
17274
+ h.onOptimistic = function (sentPayload) {
17275
+ sentMain = main.value;
17276
+ sentName = name ? name.value : "";
17277
+ main.value = "";
17278
+ if (name) name.value = "";
17279
+ return baseOptimistic ? baseOptimistic(sentPayload) : undefined;
17280
+ };
17281
+ h.onRevert = function (tok) {
17282
+ if (!main.value) main.value = sentMain;
17283
+ if (name && !name.value) name.value = sentName;
17284
+ if (baseRevert) baseRevert(tok);
17285
+ };
17286
+ if (!opts.duplicateConfirmLines) return h;
17105
17287
  // **返り値が「進めてよいか」(Promise)。** 呼び出し側は必ず await すること——
17106
17288
  // await を落とすと Promise は truthy なので**確認なしで「はい」扱いになる**。
17107
17289
  // 2026-08-20 に window.confirm から画面内のモーダルに変えた(openConfirm)。
@@ -17442,12 +17624,15 @@ ${calendarAddUrl.toString()}
17442
17624
  // 常設の接続ボタンを撤去したので、0 件の案件に顧客の Slack を足した人が接続へ進む入口はこの行だけ
17443
17625
  // (#138 型の穴を塞ぐ)。まだ gate が付いていない行(仮置き・登録の反映だけが先に届いた行)でも、
17444
17626
  // 印(orgUnknown)があれば出す。**印が無い行(測っていない)には何も出さない**
17445
- if (s.type === "slack" && s.orgUnknown === true && !s.gate && SITE.intake) {
17627
+ // 顧客の no_token(接続で直ると確かな行)も、この行のタイルの中に一手を出す(g-66)
17628
+ if (s.type === "slack" && s.gate === "no_token" && slackRowNeedsConnect(s)) {
17629
+ card.appendChild(restingHelpNode(slackRowHelpParts(s), "ds-gate-help ds-gate-resting"));
17630
+ } else if (s.type === "slack" && s.orgUnknown === true && !s.gate && SITE.intake) {
17446
17631
  var pendingParts = slackRowHelpParts(s);
17447
17632
  if (pendingParts && pendingParts.length) card.appendChild(restingHelpNode(pendingParts, "ds-gate-help ds-gate-resting"));
17448
17633
  } else if (!(opts && opts.quietHelp) && showsRestingReason(s)) {
17449
17634
  var restingParts = restingHelpPartsOf(s.type, s.gate, s);
17450
- // 空の配列は「行には何も出さない」(接続は組の先頭が言う・slackRowHelpParts)。夜間の理由文にも落とさない
17635
+ // 空の配列は「行には何も出さない」。夜間の理由文にも落とさない
17451
17636
  if (restingParts) { if (restingParts.length) card.appendChild(restingHelpNode(restingParts, "ds-gate-help ds-gate-resting")); }
17452
17637
  else if (!(String(s.type).indexOf("github") === 0 && (s.gate === "unreachable" || s.gate === "no_token") && !githubOrgRefused(s))) {
17453
17638
  // GitHub の読めない行は表が一手を決める(出さないと決めた行=確かめられなかった組織には、
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takagaki/cortex-decisions-viewer",
3
- "version": "0.12.134",
3
+ "version": "0.12.138",
4
4
  "description": "Cortexの意思決定記録(Decisions/*.md)を静的サイトにビルドして閲覧する。各レコードに「Edit on GitHub」リンクを付与する。",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,