@galda/cli 0.10.35 → 0.10.37

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/app/index.html CHANGED
@@ -192,6 +192,18 @@
192
192
  .qico:disabled{opacity:.25;cursor:default}
193
193
  .qico:disabled:hover{color:var(--ink3);background:transparent}
194
194
  .qico svg{width:14px;height:14px;stroke:currentColor;stroke-width:1.8;fill:none;stroke-linecap:round;stroke-linejoin:round}
195
+ /* Connection status line (offline) — inline color element, NEVER a painted
196
+ surface (DESIGN-RULES §1.5-8). Lives as the top row of the queue tray. The
197
+ .creconn button reuses the .errbtn idiom but re-declares its base here: #queue
198
+ sits in #composerWrap, OUTSIDE #fsRoot, so `#fsRoot .errbtn` never reaches it. */
199
+ .connline{display:flex;align-items:center;gap:8px;padding:6px 10px 6px 8px;font-family:var(--mono);font-size:10.5px;line-height:1.4}
200
+ .connline .cdot{width:6px;height:6px;border-radius:50%;flex:0 0 auto;background:var(--danger);animation:connpulse 1.6s ease-in-out infinite}
201
+ .connline .cw{font-weight:600;color:var(--danger)}
202
+ .connline .cmsg{color:var(--ink2)}
203
+ .connline .creconn{margin-left:auto;border:0;background:transparent;color:var(--ink2);font-family:var(--mono);font-size:10px;letter-spacing:.02em;font-weight:600;line-height:1.15;padding:2.5px 10px;border-radius:7px;box-shadow:inset 0 0 0 1px var(--hair2);cursor:pointer}
204
+ .connline .creconn:hover{background:var(--hover);color:var(--ink)}
205
+ @keyframes connpulse{0%,100%{opacity:1}50%{opacity:.35}}
206
+ @media (prefers-reduced-motion:reduce){.connline .cdot{animation:none}}
195
207
  .qedit{width:100%;box-sizing:border-box;background:var(--paper);border:1px solid var(--hair2);border-radius:9px;color:var(--ink);
196
208
  font:inherit;font-size:13px;line-height:1.5;padding:7px 10px;outline:none;min-height:56px;resize:vertical;margin:4px 0}
197
209
  .qbtns{display:flex;align-items:center;gap:6px;justify-content:flex-end;padding-bottom:4px}
@@ -201,6 +213,7 @@
201
213
 
202
214
  .errbar{position:absolute;left:50%;bottom:96px;transform:translateX(-50%);z-index:40;background:var(--errbg);backdrop-filter:blur(14px);
203
215
  border:1px solid rgba(var(--danger-rgb),.4);color:var(--danger);font-size:12.5px;border-radius:10px;padding:8px 14px;display:none}
216
+ .errbar.ok{border-color:rgba(var(--green-rgb),.45);color:var(--green)} /* 07-20c: neutral/success toast (PR merged) */
204
217
 
205
218
  /* composer */
206
219
  .composer-wrap{flex:0 0 auto;padding:8px 22px 18px}
@@ -400,6 +413,7 @@
400
413
  .chip2{font-family:var(--mono);font-size:9.5px;letter-spacing:.05em;padding:2px 7px;border-radius:6px;border:1px solid var(--hair2);color:var(--ink3);flex:0 0 auto}
401
414
  .chip2.doing{color:var(--ink);border-color:rgba(var(--blue-rgb),.4)}
402
415
  .chip2.review{color:var(--amber);border-color:rgba(var(--amber-rgb),.4)}
416
+ .chip2.rejected{color:var(--danger);border-color:rgba(var(--danger-rgb),.35)}
403
417
  .goalx{scroll-margin-top:24px;border-radius:12px;transition:background-color .3s}
404
418
  .goalx.selected{background-color:rgba(var(--amber-rgb),.12)}
405
419
  .prmini{font-family:var(--mono);font-size:9.5px;color:var(--green);text-decoration:none;border:1px solid rgba(var(--green-rgb),.35);border-radius:6px;padding:2px 7px;flex:0 0 auto;background:transparent;cursor:pointer}
@@ -666,6 +680,10 @@
666
680
  .delmodal{width:380px}
667
681
  .delpreview{border:1px solid var(--hair2);border-radius:10px;padding:10px 12px;background:var(--paper);
668
682
  color:var(--ink);font-size:12.5px;line-height:1.5;max-height:120px;overflow:auto;word-break:break-word}
683
+ /* 07-20c: borderless preview for the merge-confirm dialog — must NOT read as an editable field */
684
+ .delpreview.plain{border:none;background:none;padding:2px 0;max-height:none}
685
+ .delpreview.plain .pvtitle{font-weight:600;color:var(--ink);font-size:14px}
686
+ .delpreview.plain .pvpr{color:var(--ink2);font-size:11px;font-family:var(--mono);margin-top:3px}
669
687
  /* moment ③ New-project paywall dialog (H22). Same narrow .modal as .delmodal,
670
688
  but a one-action layout (Masa 2026-07-17 確定 __dlg(2)): Upgrade full-width,
671
689
  "Not now" a quiet underlined link centered below it. */
@@ -1546,6 +1564,20 @@
1546
1564
  .fsbcol:hover .fsbcadd{opacity:.7}
1547
1565
  .fsbcadd:hover{opacity:1;color:var(--ink);background:var(--hover)}
1548
1566
  .fsbcadd svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round}
1567
+ /* Doing列の一時停止/再生トグル (Masa 2026-07-18・Spotify式). Running=hoverで出る一時停止
1568
+ ❚❚ / paused=常時表示の再生 ▶ (停止状態が読める+再開できる). hoverは面を塗らずグリフ
1569
+ 色のみ (DESIGN-RULES §5.5). 中立グレー: 緑/赤はstatusに読めるので使わない. board版=col
1570
+ ヘッダの Doing ラベル隣, list版=下の .tlcap-pause が #fsTodoSec の DOING 見出し隣. */
1571
+ .fsbcpause{width:20px;height:20px;display:grid;place-items:center;border:0;background:none;color:var(--ink3);cursor:pointer;border-radius:5px;opacity:0;transition:opacity .12s,color .12s}
1572
+ .fsbcol:hover .fsbcpause{opacity:.7}
1573
+ .fsbcpause:hover{opacity:1;color:var(--ink)}
1574
+ .fsbcpause[aria-pressed="true"]{opacity:1;color:var(--ink)}
1575
+ .fsbcpause svg{width:15px;height:15px;fill:currentColor;stroke:none}
1576
+ .tlcap-pause{display:inline-grid;place-items:center;width:15px;height:15px;vertical-align:-3px;margin-left:6px;border:0;background:none;color:var(--ink3);cursor:pointer;border-radius:4px;opacity:0;transition:opacity .12s,color .12s}
1577
+ #fsTodoSec:hover .tlcap-pause{opacity:.7}
1578
+ .tlcap-pause:hover{opacity:1;color:var(--ink)}
1579
+ .tlcap-pause[aria-pressed="true"]{opacity:1;color:var(--ink)}
1580
+ .tlcap-pause svg{width:11px;height:11px;fill:currentColor;stroke:none}
1549
1581
  .fsbbody{flex:1;min-height:12px;overflow-y:auto;padding:1px 2px 6px;display:flex;flex-direction:column;gap:5px}
1550
1582
  .fsbbody::-webkit-scrollbar{width:7px}.fsbbody::-webkit-scrollbar-thumb{background:var(--hair);border-radius:6px}
1551
1583
  /* Board card = Depth (CDO, Masa 2026-07-17): a card is SCANNED, not read — #NN + a
@@ -1922,6 +1954,9 @@
1922
1954
  /* To Do timeline: order via tone + labels, no axis line (§1.5-4) */
1923
1955
  #fsRoot .tl{position:relative}
1924
1956
  #fsRoot .tlcap{display:block;font-family:var(--mono);font-size:8.5px;letter-spacing:.1em;color:var(--ink3);text-transform:uppercase;padding:13px 0 5px 0} /* left 8->0: sit the DOING/Next up/Later labels on the same spine as the section headings (Masa 2026-07-18) */
1957
+ /* 07-20c: Rejected reads as a real section heading (like Review), not the tiny mono lane captions */
1958
+ #fsRoot .tlcap.rejcap{display:flex;align-items:baseline;gap:8px;font-family:inherit;font-size:13px;letter-spacing:0;text-transform:none;color:var(--ink);padding-top:16px}
1959
+ #fsRoot .tlcap.rejcap .num{font-size:10px;color:var(--ink3)}
1925
1960
  #fsRoot .tlcap.needs{color:var(--amber)} /* "Needs you" caption tinted to the attention accent, matching the board's Needs-you column (Masa 2026-07-19). Colour on the label only — the rows below carry their own in-row colour per §1.5-8. */
1926
1961
  #fsRoot .trow{position:relative;padding:6px 6px 6px 0;border-radius:8px;font-size:12.5px;color:var(--ink);display:flex;align-items:center;gap:9px} /* left 8->0: row dot lines up with the Review/To Do headings' left edge, not 8px inside it (Masa 2026-07-18) */
1927
1962
  #fsRoot .trow .tt{color:color-mix(in srgb,var(--ink) 84%,var(--panel))} /* §13.2 soften To Do row name (not pure black); left chat feed keeps --ink */
@@ -1937,6 +1972,7 @@
1937
1972
  #fsRoot .trow .stword.int{color:var(--amber)}
1938
1973
  #fsRoot .trow .st.ok{border-color:transparent;background:rgba(var(--green-rgb),.12);position:relative}
1939
1974
  #fsRoot .trow .st.ok::after{content:"✓";position:absolute;inset:0;display:grid;place-items:center;font-size:8px;color:var(--green)}
1975
+ #fsRoot .trow .st.rej{border-color:rgba(var(--danger-rgb),.5)} /* 07-20c: rejected — a quiet danger dot (settled, not alarming) */
1940
1976
  #fsRoot .trow .tt{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1941
1977
  #fsRoot .trow .num{font-size:10px;color:var(--ink3);flex:0 0 auto}
1942
1978
  #fsRoot .trow .pct{font-size:10.5px;color:var(--green);flex:0 0 auto;font-family:var(--mono);font-variant-numeric:tabular-nums}
@@ -2798,7 +2834,7 @@
2798
2834
  <div class="reqlist" id="reviewList"></div>
2799
2835
  <div class="revactions">
2800
2836
  <button type="button" class="reqbtn approve" id="reviewApprove">Approve goal</button>
2801
- <button type="button" class="reqbtn dismiss" id="reviewDismiss">Dismiss goal</button>
2837
+ <button type="button" class="reqbtn dismiss" id="reviewDismiss">Reject</button>
2802
2838
  <span style="flex:1"></span>
2803
2839
  <button class="mbtn ghost" id="reviewClose">Close</button>
2804
2840
  </div>
@@ -2868,7 +2904,7 @@
2868
2904
  </div>
2869
2905
  <div class="sa-act">
2870
2906
  <button type="button" class="sa-app" id="gdApprove"><svg viewBox="0 0 24 24"><path d="M5 12.5l4.2 4.2L19 7"/></svg>Approve</button>
2871
- <button type="button" class="sa-dis" id="gdDismiss">Dismiss</button>
2907
+ <button type="button" class="sa-dis" id="gdDismiss">Reject</button>
2872
2908
  <div class="sa-chatwrap">
2873
2909
  <input type="text" class="sa-chat" id="gdRevisionInput" placeholder="Tell the worker what to try next…">
2874
2910
  <button type="button" class="sa-send" id="gdSend" title="Send" aria-label="Send"><svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button>
@@ -2986,6 +3022,7 @@ function restoreFocusedAnswerInput(container, attr, saved){
2986
3022
  const STATUS_LABEL = {
2987
3023
  sending: 'Sending…', stacked: 'Queued', planning: 'Planning…', queued: 'To do', running: 'Working…', needsInput: 'Needs input',
2988
3024
  review: 'In review', done: 'Done', failed: 'Failed', partial: 'Partly failed', interrupted: 'Interrupted', skipped: 'Skipped',
3025
+ rejected: 'Rejected',
2989
3026
  };
2990
3027
  // Mirrors engine/lib.mjs goalChip() — kept in sync manually since this
2991
3028
  // inline script can't import server-side code. `columns` (a project's
@@ -3004,6 +3041,7 @@ const GOAL_CHIP = (s, columns, startedAt) => s === 'stacked' || s === 'sending'
3004
3041
  s === 'done' ? [pickColumnLabel(columns, 'done', 'DONE'), ''] :
3005
3042
  s === 'retesting' ? ['RE-TESTING', 'doing'] :
3006
3043
  s === 'reverted' ? ['REVERTED', ''] :
3044
+ s === 'rejected' ? ['REJECTED', 'rejected'] :
3007
3045
  s === 'folded' ? ['FOLDED', ''] :
3008
3046
  s === 'blocked' ? ['BLOCKED', 'blocked'] : s === 'needsInput' ? ['NEEDS INPUT', 'blocked'] : ['FAIL', ''];
3009
3047
  const FINISHED = (s) => ['done', 'partial', 'failed', 'interrupted'].includes(s);
@@ -3307,11 +3345,13 @@ function buildReviewDigest({ goals, tasks } = {}){
3307
3345
  }
3308
3346
  return rows;
3309
3347
  }
3310
- // Review title: mirrors the label convention used throughout (reviewSummary.changed
3311
- // plan[0] text) pulled into one place so the review card's own title and its
3312
- // sibling-goal titles (H23 split note) stay consistent.
3348
+ // Review title: the same goal title the list view's goal header shows
3349
+ // (goalHeadText = g.text ?? g.plan[0]) so a card in "See all" and the same goal
3350
+ // in the list read identically (Masa 2026-07-19). Text-first; reviewSummary.changed
3351
+ // stays only as a last resort for a goal with no text. Pulled into one place so the
3352
+ // review card's own title and its sibling-goal titles (H23 split note) stay consistent.
3313
3353
  function saReviewTitle(g){
3314
- return String(g?.reviewSummary?.changed || g?.plan?.[0] || g?.text || '').replace(/\s+/g, ' ').trim().slice(0, 90);
3354
+ return String(g?.text || g?.plan?.[0] || g?.reviewSummary?.changed || '').replace(/\s+/g, ' ').trim().slice(0, 90);
3315
3355
  }
3316
3356
  // H23 (2026-07-18) evidence-kind detection. Mirrors engine/lib.mjs isUiChange() /
3317
3357
  // hasTestRelevantChanges() — kept in sync manually since the browser script isn't a
@@ -3941,21 +3981,35 @@ function renderQueue(){
3941
3981
  const stacked = state.goals.filter((g) => g.projectId === state.active && g.status === 'stacked')
3942
3982
  .sort((a, b) => (b.prio ?? 0) - (a.prio ?? 0) || a.id - b.id);
3943
3983
  const planning = state.goals.filter((g) => g.projectId === state.active && ['sending', 'planning'].includes(g.status));
3944
- if (!ob.length && !stacked.length && !planning.length) { q.classList.remove('show'); q.innerHTML = ''; return; }
3984
+ // Offline connection line: connOffline is the grace-debounced (~800ms) SSE-down
3985
+ // flag from the reconnect loop. When offline we always show the tray (even with
3986
+ // no queued items) so the red status line + Reconnect is visible above the composer.
3987
+ const offline = connOffline;
3988
+ if (!ob.length && !stacked.length && !planning.length && !offline) { q.classList.remove('show'); q.innerHTML = ''; return; }
3945
3989
  q.classList.add('show');
3990
+ // mirror connectionBannerState({online}) from engine/lib.mjs (a browser static file
3991
+ // can't import the module) — offline ⇒ red inline line + Reconnect atop the tray.
3992
+ const connHtml = offline
3993
+ ? '<div class="connline"><span class="cdot"></span><span class="cw">Galda is offline</span><span class="cmsg">— reconnecting…</span><button class="errbtn creconn" data-reconnect>Reconnect</button></div>'
3994
+ : '';
3946
3995
  // Optimistic outbox items (saved to localStorage BEFORE the network call) render
3947
3996
  // first — instant, offline-safe, never lost. 'unsent' rows are tap-to-resend.
3948
3997
  const obRows = ob.map((o) => {
3949
3998
  const unsent = o.status === 'unsent';
3950
3999
  const lead = unsent ? '<span class="qdot"></span>' : '<span class="spinner" style="width:9px;height:9px;flex:0 0 9px"></span>';
3951
- return `<div class="qrow"${unsent ? ` data-oresend="${o.oid}"` : ''}>${lead}<span class="qtxt">${esc((o.text ?? '').replace(/\s+/g, ' '))}</span><span class="meta">${unsent ? 'Unsent tap to resend' : 'Sending…'}</span></div>`;
4000
+ // mirror unsentMetaLabel({online}) from engine/lib.mjsoffline resend is a lie
4001
+ // (recovery is the connline's Reconnect), so drop tap-to-resend and swap the label.
4002
+ const resendable = unsent && !offline;
4003
+ const metaTxt = unsent ? (offline ? 'Waiting for connection' : 'Unsent — tap to resend') : 'Sending…';
4004
+ return `<div class="qrow"${resendable ? ` data-oresend="${o.oid}"` : ''}>${lead}<span class="qtxt">${esc((o.text ?? '').replace(/\s+/g, ' '))}</span><span class="meta">${metaTxt}</span></div>`;
3952
4005
  }).join('');
3953
4006
  const planningRows = planning.map((g) =>
3954
4007
  `<div class="qrow"><span class="spinner" style="width:9px;height:9px;flex:0 0 9px"></span><span class="qtxt">${esc((g.text ?? '').replace(/\s+/g, ' '))}</span><span class="meta">breaking down → To do…</span></div>`).join('');
3955
4008
  // Flagship: qtray label is the quiet mono "Queued · N" (flagship que1); other
3956
4009
  // layouts keep the explanatory sentence.
3957
4010
  const qn = ob.length + stacked.length + planning.length;
3958
- q.innerHTML = `<div class="qhead">${state.layout === 'flagship' ? `Queued · ${qn}` : `${qn} queued · goals become To dos in ~30s`}</div>` + obRows + planningRows + stacked.map((g) => {
4011
+ const headHtml = qn > 0 ? `<div class="qhead">${state.layout === 'flagship' ? `Queued · ${qn}` : `${qn} queued · goals become To dos in ~30s`}</div>` : '';
4012
+ q.innerHTML = connHtml + headHtml + obRows + planningRows + stacked.map((g) => {
3959
4013
  if (state.editing === g.id) {
3960
4014
  return `<textarea class="qedit" id="qedit-${g.id}">${esc(g.text)}</textarea>
3961
4015
  <div class="qbtns">
@@ -3973,6 +4027,7 @@ function renderQueue(){
3973
4027
  <button class="qico del qd" data-id="${g.id}" title="Delete">${ICONS.trash}</button></div>`;
3974
4028
  }).join('');
3975
4029
  for (const el of q.querySelectorAll('[data-oresend]')) el.onclick = () => { const item = outbox.load().find((x) => x.oid === el.dataset.oresend); if (item) postOutboxItem(item); };
4030
+ for (const el of q.querySelectorAll('[data-reconnect]')) el.onclick = () => forceReconnect();
3976
4031
  const qrows = [...q.querySelectorAll('.qrow[data-goal]')];
3977
4032
  for (const el of qrows) {
3978
4033
  el.addEventListener('dragstart', (e) => { state.dragGoal = Number(el.dataset.goal); el.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; });
@@ -4299,15 +4354,23 @@ function renderTasks(){
4299
4354
  for (const b of $('tasklist').querySelectorAll('[data-qapprove]')) {
4300
4355
  b.onclick = async (e) => { e.stopPropagation();
4301
4356
  const bx = b.getBoundingClientRect(); // capture before await/re-render — burst FROM the ✓ button
4302
- let r; for (const id of b.dataset.qapprove.split(',')) r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
4303
- if (r && r.ok) confettiBurst(bx.left + bx.width / 2, bx.top + bx.height / 2);
4304
- qrefresh(r, 'Approve failed.'); };
4357
+ let ok = true, mergeGoal = null;
4358
+ for (const id of b.dataset.qapprove.split(',')) {
4359
+ const r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
4360
+ if (!r.ok){ ok = false; break; }
4361
+ const j = await r.json(); if (j && j.needsMerge) mergeGoal = j.goal; // 07-20c: PR goal
4362
+ }
4363
+ if (!ok){ showErr('Approve failed.'); return; }
4364
+ await refresh();
4365
+ if (mergeGoal){ confirmMerge(mergeGoal); return; } // merge instead of confetti
4366
+ confettiBurst(bx.left + bx.width / 2, bx.top + bx.height / 2); };
4305
4367
  }
4306
4368
  for (const b of $('tasklist').querySelectorAll('[data-qdismiss]')) {
4369
+ // 07-20c: the inline ✕ is a pure Reject — park the goal in the Rejected column (/reject),
4370
+ // no auto-rework. Rework is asked for via chat.
4307
4371
  b.onclick = async (e) => { e.stopPropagation();
4308
- let r; for (const id of b.dataset.qdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/dismiss`), { method: 'POST',
4309
- headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: '' }) });
4310
- qrefresh(r, 'Dismiss failed.'); };
4372
+ let r; for (const id of b.dataset.qdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/reject`), { method: 'POST' });
4373
+ qrefresh(r, 'Reject failed.'); };
4311
4374
  }
4312
4375
  // Archive: retire an unfinished goal cleanly (no rework spawned). The escape
4313
4376
  // hatch for half-finished (partial/blocked) items so Attention stays drainable.
@@ -5055,7 +5118,39 @@ async function setRequirementStatus(goalId, taskId, status){
5055
5118
  // backend has never heard of, e.g. an injected verify fixture) and then
5056
5119
  // persisted server-side; a real goal's SSE 'goal' event reconciles it right
5057
5120
  // after, a no-op since the value already matches.
5121
+ // 07-20c: a review goal that HAS a PR is finished by MERGING the PR, not by approving in
5122
+ // the app (that was the "approved but PR still open" zombie). Reuses the confirmDelete modal
5123
+ // as a merge-confirm: primary action opens the PR; the goal stays in review and
5124
+ // syncGoalMerges flips it to done once GitHub reports the merge.
5125
+ async function confirmMerge(goal){
5126
+ const prNo = goal && goal.pr ? (String(goal.pr).match(/pull\/(\d+)/) ?? [, ''])[1] : '';
5127
+ const open = await confirmDelete({
5128
+ title: 'Merge the Pull Request to finish',
5129
+ body: 'This goal has a PR. Approving here does not merge it — open the PR and merge it, and it moves to Done automatically.',
5130
+ previewHtml: `<div class="pvtitle">${esc(goal?.text || '')}</div>${prNo ? `<div class="pvpr">PR #${prNo} ↗</div>` : ''}`,
5131
+ previewPlain: true,
5132
+ confirmLabel: 'Open PR', cancelLabel: 'Close', confirmDanger: false,
5133
+ });
5134
+ if (open && goal && goal.pr) window.open(goal.pr, '_blank', 'noopener');
5135
+ }
5136
+ // Shared approve-response handler for the simple approve surfaces (inline ✓, review-card ✓,
5137
+ // See-all). Returns true if the goal actually advanced (done), false if it needs a merge.
5138
+ function handleApproveJson(json, goal){
5139
+ if (json && json.needsMerge){ if (json.goal) upsert(state.goals, json.goal); confirmMerge(json.goal || goal); return false; }
5140
+ if (json) upsert(state.goals, json);
5141
+ return true;
5142
+ }
5058
5143
  async function advanceGoalAfterAllApproved(goal){
5144
+ // 07-20c: a PR goal is not finished here — route it to the merge-confirm dialog and keep
5145
+ // it in review (no optimistic done, no celebration until the PR is actually merged).
5146
+ if (goal.pr){
5147
+ try {
5148
+ const r = await fetch(withKey(`/api/goals/${goal.id}/advance-review`), { method: 'POST' });
5149
+ if (r.ok){ const j = await r.json(); if (j.goal) upsert(state.goals, j.goal); if (j.needsMerge) confirmMerge(j.goal || goal); }
5150
+ } catch { /* offline — leave as review */ }
5151
+ renderReviewChecklist(); render();
5152
+ return;
5153
+ }
5059
5154
  const fly = captureReviewFly(goal.id);
5060
5155
  goal.status = 'done';
5061
5156
  renderReviewChecklist(); render();
@@ -5125,8 +5220,16 @@ $('reviewApprove').onclick = async () => {
5125
5220
  try {
5126
5221
  const r = await fetch(withKey(`/api/goals/${goal.id}/approve`), { method: 'POST' });
5127
5222
  if (!r.ok) throw new Error();
5223
+ const j = await r.json();
5224
+ if (j.needsMerge){ // 07-20c: PR goal — send to the merge dialog, no celebration/advance
5225
+ if (j.goal) upsert(state.goals, j.goal);
5226
+ renderReviewChecklist();
5227
+ $('reviewApprove').disabled = false;
5228
+ confirmMerge(j.goal || goal);
5229
+ return;
5230
+ }
5128
5231
  const fly = captureReviewFly(goal.id);
5129
- upsert(state.goals, await r.json());
5232
+ upsert(state.goals, j);
5130
5233
  renderReviewChecklist();
5131
5234
  const dm = $('reviewDoneMsg');
5132
5235
  if (dm){ dm.classList.remove('pop'); void dm.offsetWidth; dm.classList.add('pop'); }
@@ -5175,7 +5278,21 @@ async function sendReviewDismiss(text){
5175
5278
  render();
5176
5279
  } catch { showErr('Dismiss failed.'); }
5177
5280
  }
5178
- $('reviewDismiss').onclick = () => sendReviewDismiss('');
5281
+ // 07-20c: the review dialog's Reject button parks the goal in the Rejected column (/reject),
5282
+ // no auto-rework. Typing feedback into the box below and pressing Send still reworks (/dismiss).
5283
+ async function sendReviewReject(){
5284
+ const goal = state.goals.find((g) => g.id === state.reviewOpenGoal);
5285
+ if (!goal) return;
5286
+ try {
5287
+ const r = await fetch(withKey(`/api/goals/${goal.id}/reject`), { method: 'POST' });
5288
+ if (!r.ok) throw new Error();
5289
+ const body = await r.json();
5290
+ upsert(state.goals, body.goal);
5291
+ closeReviewChecklist();
5292
+ render();
5293
+ } catch { showErr('Reject failed.'); }
5294
+ }
5295
+ $('reviewDismiss').onclick = () => sendReviewReject();
5179
5296
  $('reviewFixSend').onclick = () => { const t = $('reviewFixInput').value.trim(); if (t || reviewAttach.length) sendReviewDismiss(t); };
5180
5297
  $('reviewFixInput').addEventListener('keydown', (e) => {
5181
5298
  if (e.key === 'Enter') { const t = $('reviewFixInput').value.trim(); if (t || reviewAttach.length) sendReviewDismiss(t); }
@@ -5356,12 +5473,26 @@ $('gdApprove').onclick = async () => {
5356
5473
  try {
5357
5474
  const r = await fetch(withKey(`/api/goals/${goalId}/approve`), { method: 'POST' });
5358
5475
  if (!r.ok) throw new Error();
5359
- upsert(state.goals, await r.json());
5476
+ handleApproveJson(await r.json(), state.goals.find((g) => g.id === goalId)); // 07-20c: PR goal → merge dialog
5360
5477
  closeGoalDetail();
5361
5478
  render();
5362
5479
  } catch { showErr('Approve failed.'); }
5363
5480
  $('gdApprove').disabled = false;
5364
5481
  };
5482
+ // 07-20c: the goal-detail Reject button parks the goal in the Rejected column (/reject).
5483
+ // Revision text typed into gdSend still goes to /dismiss (rework) via sendDismiss below.
5484
+ async function sendReject(goalId){
5485
+ $('gdApprove').disabled = true; $('gdDismiss').disabled = true; $('gdSend').disabled = true;
5486
+ try {
5487
+ const r = await fetch(withKey(`/api/goals/${goalId}/reject`), { method: 'POST' });
5488
+ if (!r.ok) throw new Error();
5489
+ const body = await r.json();
5490
+ upsert(state.goals, body.goal);
5491
+ closeGoalDetail();
5492
+ render();
5493
+ } catch { showErr('Reject failed.'); }
5494
+ $('gdApprove').disabled = false; $('gdDismiss').disabled = false; $('gdSend').disabled = false;
5495
+ }
5365
5496
  async function sendDismiss(goalId, text){
5366
5497
  $('gdApprove').disabled = true; $('gdDismiss').disabled = true; $('gdSend').disabled = true;
5367
5498
  try {
@@ -5402,7 +5533,7 @@ async function discardGoal(goalId){
5402
5533
  } catch { showErr('Discard failed.'); }
5403
5534
  $('gdSend').disabled = false; $('gdDiscard').disabled = false;
5404
5535
  }
5405
- $('gdDismiss').onclick = () => { const goalId = state.goalDetailOpen; if (goalId != null) sendDismiss(goalId, ''); };
5536
+ $('gdDismiss').onclick = () => { const goalId = state.goalDetailOpen; if (goalId != null) sendReject(goalId); };
5406
5537
  $('gdDiscard').onclick = () => { const goalId = state.goalDetailOpen; if (goalId != null) discardGoal(goalId); };
5407
5538
  $('gdSend').onclick = () => {
5408
5539
  const goalId = state.goalDetailOpen;
@@ -5446,7 +5577,7 @@ const fsUI = {
5446
5577
  rv: [1, 2, 3].includes(Number(localStorage.getItem('fsRv'))) ? Number(localStorage.getItem('fsRv')) : 3, // review view: 3 thumbnails (default) / 1 list / 2 compact
5447
5578
  band: Number(localStorage.getItem('fsBand') ?? 0), // theme-6 band choice
5448
5579
  revMore: false, // "+N more" fold on the review list (§1.5-8)
5449
- todoFold: false, doneOpen: false, todoMore: false,
5580
+ todoFold: false, doneOpen: false, rejOpen: false, todoMore: false,
5450
5581
  todoView: localStorage.getItem('fsTodoView') === 'board' ? 'board' : 'list', // To Do: list (default) / kanban board (Masa 2026-07-16)
5451
5582
  logOpen: new Set(), logFocus: null, // "View log" targets
5452
5583
  repsOpen: new Set(), // reply-group folds
@@ -5455,6 +5586,7 @@ const FS_ICONS = {
5455
5586
  reply: '<svg viewBox="0 0 24 24"><path d="M10 16l-5-4 5-4"/><path d="M5 12h9a4 4 0 0 1 4 4v2"/></svg>',
5456
5587
  edit: '<svg viewBox="0 0 24 24"><path d="M4 20h4L18.5 9.5a2.1 2.1 0 0 0-3-3L5 17v3z"/><path d="M14 7l3 3"/></svg>',
5457
5588
  del: '<svg viewBox="0 0 24 24"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg>',
5589
+ reopen: '<svg viewBox="0 0 24 24"><g transform="translate(24,0) scale(-1,1)"><polyline points="3 5 3 10 8 10"/><path d="M5.6 15.4A8 8 0 1 0 6 8.3L3 10.8"/></g></svg>', /* 07-20c: rotate arrow (mirrored → arrowhead top-right) = reopen/restore */
5458
5590
  };
5459
5591
  // per-project count icons (HANDOFF-v45 §10): To Do = rounded square, Review = Scan
5460
5592
  // (magnifier) — both stroked, NEUTRAL ink (never green); shape is the only distinction.
@@ -5759,6 +5891,7 @@ function fsBoardBucket(goal, tasks){
5759
5891
  const s = goal?.status;
5760
5892
  if (s === 'pending') return 'later'; // shelved by the composer's "Later"
5761
5893
  if (s === 'needsInput') return 'needs'; // a clarifying question is waiting on the user
5894
+ if (s === 'blocked' && goal?.blocked?.kind === 'paused') return 'doing'; // paused by user — stays in Doing, shown amber "Paused" (Masa 2026-07-19: 1カードのまま黄色に・Needs youへ移動しない)
5762
5895
  if (['blocked', 'failed', 'partial', 'interrupted'].includes(s)) return 'needs'; // stopped/errored — needs YOUR action (retry), like the flagship board which puts .drow.err in Needs you. Not "Doing" — Doing is only what's actively moving (Masa 2026-07-18)
5763
5896
  if (s === 'review') return 'review';
5764
5897
  if (s === 'done' || s === 'reverted' || s === 'skipped') return 'done'; // terminal; skipped is finished, not in flight (it used to fall through to the doing default)
@@ -5788,6 +5921,7 @@ function fsBoardBucket(goal, tasks){
5788
5921
  // above (proof thumbnails, PR links, approve/dismiss) — the board doesn't duplicate it.
5789
5922
  function renderFsBoard(){
5790
5923
  const el = $('fsBoard'); if (!el) return;
5924
+ const paused = !!state.projects.find((p) => p.id === state.active)?.paused; // Doing列トグルの状態
5791
5925
  const { pgoals, list } = fsBucketTodo();
5792
5926
  const tt = (s, n) => esc(String(s ?? '').replace(/\s+/g, ' ').slice(0, n ?? 60));
5793
5927
  const goalNo = new Map();
@@ -5798,7 +5932,7 @@ function renderFsBoard(){
5798
5932
  // flagship mock; a new goal enters via the composer). Earlier this was To-Do-only, which read
5799
5933
  // as "position is off" against the Artifact (Masa 2026-07-18).
5800
5934
  const col = (name, stg, count, html, stage) =>
5801
- `<section class="fsbcol" data-stage="${stage}" style="--stg:${stg}"><div class="fsbcolhd"><span class="fsbdot"></span><span class="fsbcolnm">${name}</span><span class="fsbcnt">${count}</span><button class="fsbcadd" data-fsnew="1" title="New goal"><svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg></button></div><div class="fsbbody">${html || '<div class="fsbempty">—</div>'}</div><button class="fsbadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button></section>`;
5935
+ `<section class="fsbcol" data-stage="${stage}" style="--stg:${stg}"><div class="fsbcolhd"><span class="fsbdot"></span><span class="fsbcolnm">${name}</span>${stage === 'doing' ? `<button class="fsbcpause" data-fspause="1" aria-pressed="${paused ? 'true' : 'false'}" title="${paused ? 'Resume — 再開' : 'Pause — 一時停止'}">${paused ? '<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>' : '<svg viewBox="0 0 24 24"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg>'}</button>` : ''}<span class="fsbcnt">${count}</span><button class="fsbcadd" data-fsnew="1" title="New goal"><svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg></button></div><div class="fsbbody">${html || '<div class="fsbempty">—</div>'}</div><button class="fsbadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button></section>`;
5802
5936
  // Depth card (CDO, Masa 2026-07-17): #NN + title, colour on the dot only, and a continuous
5803
5937
  // stroke + n/m ONLY while the goal is actually in flight. The column already names the
5804
5938
  // status, so the card never repeats it. Stopped/failed carry NO bar (a dead bar would claim
@@ -5810,9 +5944,11 @@ function renderFsBoard(){
5810
5944
  // not Doing). Doing holds only what's actively moving, so bkt==='doing' ⇒ healthy in flight.
5811
5945
  const errored = ['blocked', 'failed', 'partial', 'interrupted'].includes(g.status);
5812
5946
  const intr = g.status === 'interrupted';
5813
- const healthyRun = bkt === 'doing';
5947
+ const isPaused = g.status === 'blocked' && g.blocked?.kind === 'paused'; // 一時停止=エラーでなくアンバー扱い
5948
+ const amber = intr || isPaused;
5949
+ const healthyRun = bkt === 'doing' && !errored; // paused(=errored) goals now sit in 'doing' too but carry no progress bar
5814
5950
  let dot = '', word = '';
5815
- if (errored) { dot = intr ? 'intr' : 'bad'; word = intr ? 'Interrupted' : 'Failed'; } // shown in Needs you, with the word
5951
+ if (errored) { dot = amber ? 'intr' : 'bad'; word = isPaused ? 'Paused' : intr ? 'Interrupted' : 'Failed'; } // shown in Needs you, with the word
5816
5952
  else if (bkt === 'review') dot = 'rev';
5817
5953
  else if (bkt === 'done') dot = 'ok';
5818
5954
  else if (bkt === 'needs') dot = 'intr'; // your turn — a clarifying question is waiting
@@ -5823,7 +5959,7 @@ function renderFsBoard(){
5823
5959
  let h = '<div class="lbc-hd">'
5824
5960
  + (dot ? `<span class="lbc-dot ${dot}"></span>` : '')
5825
5961
  + (n != null ? `<span class="lbc-n">#${n}</span>` : '')
5826
- + (word ? `<span class="lbc-w ${intr ? 'intr' : 'bad'}">${word}</span>` : '')
5962
+ + (word ? `<span class="lbc-w ${amber ? 'intr' : 'bad'}">${word}</span>` : '')
5827
5963
  + '</div>'
5828
5964
  + `<div class="lbc-t">${tt(g.text || g.plan?.[0], 90)}</div>`;
5829
5965
  // n/m is Doing-only (Masa 2026-07-17): a fraction on a stopped/queued/done card is
@@ -5854,8 +5990,22 @@ function renderFsBoard(){
5854
5990
  + col('Done', 'var(--ink3)', bucket.done.length, bucket.done.slice(0, 40).map(goalCard).join(''), 'done')
5855
5991
  + col('Later', 'var(--ink3)', bucket.later.length, bucket.later.map(goalCard).join(''), 'later');
5856
5992
  for (const b of el.querySelectorAll('[data-fsnew]')) b.onclick = () => $('input')?.focus(); // every + New / header + focuses the composer
5993
+ for (const b of el.querySelectorAll('[data-fspause]')) b.onclick = togglePauseActive; // Doing列の一時停止/再生
5857
5994
  renderFsWait(bucket);
5858
5995
  }
5996
+ // Doing列の一時停止/再生 (Masa 2026-07-18・Spotify式トグル). 楽観的にローカルの
5997
+ // project.paused を反転して即描画→POST. サーバは pause/resume 後に {ev:'projects'}
5998
+ // を broadcast するので、確定状態はSSEハンドラが state.projects を上書きして一致
5999
+ // させる. POST失敗時だけ元に戻す. board/list 両方の [data-fspause] が共有する。
6000
+ function togglePauseActive(){
6001
+ const p = state.projects.find((x) => x.id === state.active);
6002
+ if (!p) return;
6003
+ const next = !p.paused;
6004
+ p.paused = next; render();
6005
+ fetch(withKey(`/api/projects/${state.active}/${next ? 'pause' : 'resume'}`), { method: 'POST', headers: { 'content-type': 'application/json' } })
6006
+ .then((r) => { if (!r.ok) throw new Error(`pause toggle ${r.status}`); })
6007
+ .catch(() => { p.paused = !next; render(); });
6008
+ }
5859
6009
  // Waiting badge (CDO, Masa 2026-07-17 decision 2): the board keeps work-flow column order, so
5860
6010
  // the "your turn" columns (Needs you, Review) can sit off the right edge at a narrow lane
5861
6011
  // width. They announce themselves beside the List/Board toggle instead — pure read of the same
@@ -5952,14 +6102,19 @@ function renderFsReview(){
5952
6102
  const cnote = carried.length ? `<span class="fnote">${foldLangFor(g0) === 'ja'
5953
6103
  ? `+ ${carried.map((x) => `#${x.id}`).join(', ')} の仕事もここに入っています`
5954
6104
  : `also carries ${carried.map((x) => `#${x.id}`).join(', ')}`}</span>` : '';
5955
- return `<div class="rcard${img ? '' : ' noimg'}" data-fsrev="${r.goalIds[0]}">${img}<div class="rc"><div class="t">${rid}${esc(r.checkLine)}</div>${rnote}${cnote}${pr}</div>
6105
+ // Title = the goal's own title (saReviewTitle → text-first), same as the See-all
6106
+ // Ledger and the To Do list, so a review card and its list row read identically
6107
+ // (Masa 2026-07-20: this card showed reviewSummary.check "…が出ない" while the list
6108
+ // showed the request text). Plan-reviews keep their "PLAN — approve to execute:"
6109
+ // checkLine, which is the actionable label there, not a title mismatch.
6110
+ return `<div class="rcard${img ? '' : ' noimg'}" data-fsrev="${r.goalIds[0]}">${img}<div class="rc"><div class="t">${rid}${esc(r.plan ? r.checkLine : saReviewTitle(g0))}</div>${rnote}${cnote}${pr}</div>
5956
6111
  <div class="act"><button class="b" data-fsapprove="${ids}" title="Approve">✓</button><button class="b no" data-fsdismiss="${ids}" title="Send back">✕</button></div></div>`;
5957
6112
  };
5958
6113
  // A folded goal is settled, not failed: no ✓/✕ (the thing to approve lives on the goal
5959
6114
  // it was folded into), no red, no painted surface (§1.5-8) — just its own number, what
5960
6115
  // it asked for, and where the work went. Tapping it opens that goal (§1.5-9 wording).
5961
6116
  const foldCard = (g) => {
5962
- const line = String(g.reviewSummary?.check || g.plan?.[0] || g.text || '').replace(/\s+/g, ' ').slice(0, 60);
6117
+ const line = saReviewTitle(g).slice(0, 60); // title-first, same as the review card above
5963
6118
  const mergedNote = foldLangFor(g) === 'ja' ? `→ #${esc(String(g.foldedInto))} にまとめました` : `merged into #${esc(String(g.foldedInto))}`;
5964
6119
  return `<div class="rcard noimg fold" data-fsfold="${g.id}">
5965
6120
  <div class="rc"><div class="t"><span class="rid gno" data-n="${g.id}">#${g.id}</span>${esc(line)}</div>
@@ -6014,12 +6169,19 @@ function renderFsReview(){
6014
6169
  else openGoalDetail(target.id);
6015
6170
  };
6016
6171
  for (const b of el.querySelectorAll('[data-fsapprove]')) b.onclick = async (e) => { e.stopPropagation();
6017
- let r; for (const id of b.dataset.fsapprove.split(',')) r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
6018
- if (r && r.ok) { celebrateApprove(); await refresh(); } else showErr('Approve failed.'); };
6172
+ let ok = true, mergeGoal = null;
6173
+ for (const id of b.dataset.fsapprove.split(',')) {
6174
+ const r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
6175
+ if (!r.ok){ ok = false; break; }
6176
+ const j = await r.json(); if (j && j.needsMerge) mergeGoal = j.goal; // 07-20c: PR goal
6177
+ }
6178
+ if (!ok){ showErr('Approve failed.'); return; }
6179
+ await refresh();
6180
+ if (mergeGoal) confirmMerge(mergeGoal); else celebrateApprove(); };
6019
6181
  for (const b of el.querySelectorAll('[data-fsdismiss]')) b.onclick = async (e) => { e.stopPropagation();
6020
- let r; for (const id of b.dataset.fsdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/dismiss`), { method: 'POST',
6021
- headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: '' }) });
6022
- if (r && r.ok) await refresh(); else showErr('Dismiss failed.'); };
6182
+ // 07-20c: the review-card is a pure Reject Rejected column (/reject), no auto-rework.
6183
+ let r; for (const id of b.dataset.fsdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/reject`), { method: 'POST' });
6184
+ if (r && r.ok) await refresh(); else showErr('Reject failed.'); };
6023
6185
  }
6024
6186
  // Shared To Do bucketing (list rows AND the kanban board read the same groups —
6025
6187
  // single source of truth, see renderFsBoard below). Pure of the DOM: state in, groups out.
@@ -6038,13 +6200,18 @@ function fsBucketTodo(){
6038
6200
  queued: list.filter((t) => t.status === 'queued' && !t.reply).sort(byQueue),
6039
6201
  doneAll: list.filter((t) => ['done', 'skipped'].includes(t.status)).sort((a, b) => b.num - a.num),
6040
6202
  later: pgoals.filter((g) => g.status === 'pending'),
6203
+ rejected: pgoals.filter((g) => g.status === 'rejected'), // 07-20c: Reject parks goals here (own fold, like Done)
6041
6204
  };
6042
6205
  }
6043
6206
  // To Do (below Review): Doing rows with bar + mono %, Next up in queue order,
6044
6207
  // error as a small in-row colour element (§1.5-8), quiet Done fold.
6045
6208
  function renderFsTodo(){
6046
6209
  const el = $('fsTodoSec'); if (!el) return;
6047
- const { pgoals, list, running, failed, blocked, needsInput, queued, doneAll, later } = fsBucketTodo();
6210
+ const { pgoals, list, running, failed, blocked, needsInput, queued, doneAll, later, rejected } = fsBucketTodo();
6211
+ const paused = !!state.projects.find((p) => p.id === state.active)?.paused; // list版 Doing トグルの状態
6212
+ // 一時停止したゴール(blocked kind:'paused')は Doing に「Paused」1行で留める(Masa 2026-07-19)。
6213
+ // Needs you 側(failed/blocked)へは出さず、その走行/中断タスク行も抑止して二重行を作らない。
6214
+ const pausedGoalIds = new Set(pgoals.filter((g) => g.status === 'blocked' && g.blocked?.kind === 'paused').map((g) => g.id));
6048
6215
  const tt = (s, n) => esc(String(s ?? '').replace(/\s+/g, ' ').slice(0, n ?? 70));
6049
6216
  // LATER = deliberately shelved goals (composer "Later" send → server status 'pending';
6050
6217
  // PRD §8.2 top gap — they were invisible in flagship). Staged disclosure: the cap
@@ -6063,8 +6230,10 @@ function renderFsTodo(){
6063
6230
  // out to its own "Needs you" caption below (Masa 2026-07-19) — folding it into Doing read
6064
6231
  // as "still in flight".
6065
6232
  const doingRows =
6066
- running.map((t) => `<div class="drow"><div class="trow now"><span class="st run"></span>${noSpan(t.goalId)}<span class="tt">${tt(t.title)}</span><span class="pct num">${progressFor(t)}%</span>${fsActs(t.goalId)}</div>
6067
- <div class="tbar"><i style="width:${progressFor(t)}%"></i></div></div>`).join('');
6233
+ running.filter((t) => !pausedGoalIds.has(t.goalId)).map((t) => `<div class="drow"><div class="trow now"><span class="st run"></span>${noSpan(t.goalId)}<span class="tt">${tt(t.title)}</span><span class="pct num">${progressFor(t)}%</span>${fsActs(t.goalId)}</div>
6234
+ <div class="tbar"><i style="width:${progressFor(t)}%"></i></div></div>`).join('')
6235
+ // 一時停止したゴールは Doing に「Paused」1行(アンバー)で留める+Resume。走行/中断行は上と needsRows で抑止済み。
6236
+ + pgoals.filter((g) => pausedGoalIds.has(g.id)).map((g) => `<div class="drow"><div class="trow now"><span class="st run int"></span>${noSpan(g.id)}<span class="tt">${tt(g.reviewSummary?.check || g.plan?.[0] || g.text, 60)}</span><span class="stword int">Paused</span><button class="ab txt" data-fspause="1" title="Resume — 再開">Resume</button></div></div>`).join('');
6068
6237
  // NEEDS YOU: stopped/errored work that used to fold into Doing, now under its own caption so
6069
6238
  // it no longer reads as "still in flight" (Masa 2026-07-19). Three kinds share it, each
6070
6239
  // keeping the affordances it already had — nothing about the failed-task and blocked-goal
@@ -6081,7 +6250,7 @@ function renderFsTodo(){
6081
6250
  // both still call the same /retry endpoint, which already resumes the session when one
6082
6251
  // exists (server-side retry = "resume if possible, else re-run"; there is no separate
6083
6252
  // resume endpoint to call).
6084
- failed.map((t) => { const isInt = t.status === 'interrupted';
6253
+ failed.filter((t) => !pausedGoalIds.has(t.goalId)).map((t) => { const isInt = t.status === 'interrupted';
6085
6254
  const word = isInt ? 'Interrupted' : 'Failed', icon = isInt ? '⏸' : '✗', label = isInt ? 'Resume' : 'Retry';
6086
6255
  // don't restate the state word as the "reason" too (e.g. "Interrupted / Interrupted")
6087
6256
  // when the task has no distinct result text — the coloured word already said it, so
@@ -6092,7 +6261,7 @@ function renderFsTodo(){
6092
6261
  return `<div class="drow err${isInt ? ' int' : ''}"><div class="trow now"><span class="st run ${isInt ? 'int' : 'bad'}"></span>${noSpan(t.goalId)}<span class="tt">${tt(t.title)}</span><span class="stword ${isInt ? 'int' : ''}">${word}</span>${fsActs(t.goalId)}</div>
6093
6262
  <div class="errline">${reasonLine}
6094
6263
  <div class="acts2"><button class="errbtn" data-fsretry="${t.id}">${label}</button><button class="errbtn" data-fslog="${t.id}">View log</button></div></div></div>`; }).join('')
6095
- + blocked.map((g) => `<div class="drow err"><div class="trow now"><span class="st run bad"></span>${noSpan(g.id)}<span class="tt">${tt(g.reviewSummary?.check || g.plan?.[0] || g.text, 60)}</span><span class="stword">Failed</span></div>
6264
+ + blocked.map((g) => g.blocked?.kind === 'paused' ? '' : `<div class="drow err"><div class="trow now"><span class="st run bad"></span>${noSpan(g.id)}<span class="tt">${tt(g.reviewSummary?.check || g.plan?.[0] || g.text, 60)}</span><span class="stword">Failed</span></div>
6096
6265
  <div class="errline"><div class="msg"><span class="x">✗</span>${tt(g.blocked?.reason ?? 'blocked', 180)}</div>
6097
6266
  <div class="acts2"><button class="errbtn" data-fsreverify="${g.id}">Retry</button><button class="errbtn" data-fsglog="${g.id}">View log</button><button class="errbtn" data-fsarchive="${g.id}">Archive</button></div></div></div>`).join('')
6098
6267
  + needsInput.map((g) => `<div class="drow err int"><div class="trow now"><span class="st run int"></span>${noSpan(g.id)}<span class="tt">${tt(g.text || g.plan?.[0], 60)}</span><span class="stword int">Needs input</span></div>
@@ -6137,9 +6306,10 @@ function renderFsTodo(){
6137
6306
  : `<span class="num fstdlabel">${todoTotal}${queued.length ? ' · next-up order' : ''}</span>`;
6138
6307
  el.innerHTML = `<div class="lhd sechd" id="fsTodoHd"><span class="hd fstdlabel">To Do</span>${_todoNum}<span class="fold fstdlabel">▾</span></div>
6139
6308
  <div class="tl">
6140
- ${doingRows ? `<span class="tlcap" style="padding-top:0">Doing</span>${doingRows}` : ''}
6141
- ${needsRows ? `<span class="tlcap needs"${doingRows ? '' : ' style="padding-top:0"'}>Needs you</span>${needsRows}` : ''}
6142
- ${upRows ? `<span class="tlcap"${doingRows || needsRows ? '' : ' style="padding-top:0"'}>Next up</span>${upRows}` : ''}
6309
+ ${(doingRows || paused) ? `<span class="tlcap tlcap-doing" style="padding-top:0">Doing<button class="tlcap-pause" data-fspause="1" aria-pressed="${paused ? 'true' : 'false'}" title="${paused ? 'Resume — 再開' : 'Pause — 一時停止'}">${paused ? '<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>' : '<svg viewBox="0 0 24 24"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg>'}</button></span>${doingRows}` : ''}
6310
+ ${needsRows ? `<span class="tlcap needs"${(doingRows || paused) ? '' : ' style="padding-top:0"'}>Needs you</span>${needsRows}` : ''}
6311
+ ${rejected.length ? `<span class="tlcap rejcap"${(doingRows || paused) || needsRows ? '' : ' style="padding-top:0"'}>Rejected<span class="num">${rejected.length}</span></span>${rejected.map((g) => `<div class="trow up rej"><span class="st rej"></span>${noSpan(g.id)}<span class="tt">${tt(g.text, 70)}</span><span class="acts"><button class="ab" data-fsreopen="${g.id}" title="Reopen — back to Review">${FS_ICONS.reopen}</button><button class="ab" data-fsrejdel="${g.id}" title="Delete — discard (closes the PR)">${FS_ICONS.del}</button></span></div>`).join('')}` : ''}
6312
+ ${upRows ? `<span class="tlcap"${(doingRows || paused) || needsRows || rejected.length ? '' : ' style="padding-top:0"'}>Next up</span>${upRows}` : ''}
6143
6313
  ${queued.length > NCAP ? `<div class="morefold" id="fsTodoMore">${fsUI.todoMore ? 'less' : `+${queued.length - NCAP} more`}<span class="g">›</span></div>` : ''}
6144
6314
  ${laterRows ? `<span class="tlcap">Later</span>${laterRows}` : ''}
6145
6315
  <button class="qadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button>
@@ -6152,6 +6322,16 @@ function renderFsTodo(){
6152
6322
  if (qadd) qadd.onclick = () => $('input')?.focus();
6153
6323
  const dl = el.querySelector('#fsDoneLine');
6154
6324
  if (dl) dl.onclick = () => { fsUI.doneOpen = !fsUI.doneOpen; dl.classList.toggle('open', fsUI.doneOpen); el.querySelector('#fsDonelist').hidden = !fsUI.doneOpen; };
6325
+ for (const b of el.querySelectorAll('[data-fsreopen]')) b.onclick = async (e) => { e.stopPropagation();
6326
+ const r = await fetch(withKey(`/api/goals/${b.dataset.fsreopen}/reopen`), { method: 'POST' });
6327
+ if (!r.ok) showErr('Reopen failed.'); else await refresh(); };
6328
+ for (const b of el.querySelectorAll('[data-fsrejdel]')) b.onclick = async (e) => { e.stopPropagation();
6329
+ const preview = b.closest('.trow')?.querySelector('.tt')?.textContent || '';
6330
+ // Delete on a rejected goal = revert: it closes the PR + drops the worktree, so a rejected
6331
+ // goal that had a PR doesn't leave an orphan PR behind (DELETE would 409 — rejected isn't deletable).
6332
+ if (!(await confirmDelete({ title: 'Discard this rejected goal?', preview }))) return;
6333
+ const r = await fetch(withKey(`/api/goals/${b.dataset.fsrejdel}/revert`), { method: 'POST' });
6334
+ if (!r.ok) showErr('Delete failed.'); else await refresh(); };
6155
6335
  const more = el.querySelector('#fsTodoMore');
6156
6336
  if (more) more.onclick = () => { fsUI.todoMore = !fsUI.todoMore; renderFsTodo(); };
6157
6337
  for (const b of el.querySelectorAll('[data-fsreps]')) b.onclick = () => {
@@ -6159,6 +6339,7 @@ function renderFsTodo(){
6159
6339
  fsUI.repsOpen.has(id) ? fsUI.repsOpen.delete(id) : fsUI.repsOpen.add(id);
6160
6340
  b.closest('.grp').classList.toggle('open');
6161
6341
  };
6342
+ for (const b of el.querySelectorAll('[data-fspause]')) b.onclick = togglePauseActive; // list版 Doing 一時停止/再生(+Paused行のResume)
6162
6343
  for (const b of el.querySelectorAll('[data-fsretry]')) b.onclick = () => fsRetryTask(b.dataset.fsretry);
6163
6344
  for (const b of el.querySelectorAll('[data-fslog]')) b.onclick = () => fsToggleLog(Number(b.dataset.fslog));
6164
6345
  for (const b of el.querySelectorAll('[data-fsglog]')) b.onclick = () => toggleGoalDetail(Number(b.dataset.fsglog));
@@ -6473,7 +6654,20 @@ function renderClawdHero(){
6473
6654
  $('fsRailTg').innerHTML = tgSvg('left', !root.classList.contains('railfold'));
6474
6655
  $('fsLaneTg').innerHTML = tgSvg('right', !root.classList.contains('lanefold'));
6475
6656
  };
6476
- $('fsRailTg').onclick = () => { root.classList.toggle('railfold'); tgUpdate(); fsGearAlignX(); }; // §1.5-7: fold
6657
+ $('fsRailTg').onclick = () => { // §1.5-7: fold
6658
+ root.classList.toggle('railfold'); tgUpdate();
6659
+ // Re-align the gear only AFTER the rail's fold animation settles. The old
6660
+ // synchronous fsGearAlignX() measured a half-collapsed rail mid-transition and
6661
+ // left the gear at a stale offset once the width finished moving — "たたむと歯車が
6662
+ // ズレる" (#474/#548, Masa 2026-07-19). Clear any stale offset now, re-measure on
6663
+ // transitionend (flex-basis), with a timeout fallback for reduced-motion / .noanim.
6664
+ const btn = $('fsMoreBtn'); if (btn) btn.style.transform = '';
6665
+ const rail = root.querySelector('.fsrail');
6666
+ let aligned = false;
6667
+ const align = () => { if (aligned) return; aligned = true; fsGearAlignX(); };
6668
+ if (rail) rail.addEventListener('transitionend', function te(e){ if (e.propertyName !== 'flex-basis') return; rail.removeEventListener('transitionend', te); align(); });
6669
+ setTimeout(align, 340);
6670
+ };
6477
6671
  $('fsLaneTg').onclick = () => { root.classList.toggle('lanefold'); tgUpdate(); };
6478
6672
  // List/Board toggle — wired once (static, unlike the row it used to live inside which
6479
6673
  // rebuilds on every renderFsTodo()); syncFsTvSeg() paints .on and is called from renderFsLane.
@@ -6717,12 +6911,22 @@ function clearBillingBlocked(){
6717
6911
  }
6718
6912
  function showErr(msg){
6719
6913
  const bar = $('errbar');
6720
- bar.classList.remove('billing'); // shed the paywall styling if it was last used for that
6914
+ bar.classList.remove('billing', 'ok'); // shed the paywall/success styling if it was last used for that
6721
6915
  billingBlockedActive = false;
6722
6916
  bar.textContent = msg; bar.style.display = 'block';
6723
6917
  clearTimeout(errTimer);
6724
6918
  errTimer = setTimeout(() => { if (authDown) return; bar.style.display = 'none'; }, 6000);
6725
6919
  }
6920
+ // 07-20c: a neutral/success toast (green), reusing the #errbar surface. Used when a PR merge
6921
+ // lands back in the app (review→done via syncGoalMerges).
6922
+ function showToast(msg){
6923
+ const bar = $('errbar');
6924
+ bar.classList.remove('billing'); bar.classList.add('ok');
6925
+ billingBlockedActive = false;
6926
+ bar.textContent = msg; bar.style.display = 'block';
6927
+ clearTimeout(errTimer);
6928
+ errTimer = setTimeout(() => { bar.classList.remove('ok'); bar.style.display = 'none'; }, 4000);
6929
+ }
6726
6930
 
6727
6931
  // Free-limit copy (H22, Masa 2026-07-17 確定 · ja+en). The sentence is content,
6728
6932
  // so it follows the user's language; the mono label and buttons stay English
@@ -7070,15 +7274,55 @@ function scheduleActRender(){ if (_actT) { _actP = true; return; } renderActPane
7070
7274
  // (connection refused = fatal, no auto-retry), so we own the reconnect loop.
7071
7275
  let esRetryMs = 1000;
7072
7276
  let sseUp = false; // live engine link — the honest signal behind the MCP tab's status dot (v46)
7277
+ // Connection status line (queue tray): grace-debounce the offline state ~800ms so
7278
+ // sub-second SSE blips don't flash a red line. connOffline is the debounced flag
7279
+ // renderQueue() reads; connSetOnline/Offline flip it around real sseUp changes.
7280
+ let connOffline = false, _connGraceT = null, _sseReconnectT = null, _es = null, _healthT = null;
7281
+ function connSetOnline(){ if (_connGraceT) { clearTimeout(_connGraceT); _connGraceT = null; } if (connOffline) { connOffline = false; renderQueue(); } }
7282
+ function connSetOffline(){ if (_connGraceT || connOffline) return; _connGraceT = setTimeout(() => { _connGraceT = null; connOffline = true; renderQueue(); }, 800); }
7283
+ // Reconnect button: drop any pending backoff timer and reopen the EventSource now.
7284
+ function forceReconnect(){ if (_sseReconnectT) { clearTimeout(_sseReconnectT); _sseReconnectT = null; } try { _es && _es.close(); } catch { /* already closed */ } esRetryMs = 1000; connectSSE(); }
7285
+ // Robust liveness backstop that does NOT depend on EventSource onerror firing.
7286
+ // The browser throttles background tabs, so onerror can be delayed or never fire and
7287
+ // sseUp goes stale-true — that is why a dropped link showed no red line until the tab
7288
+ // was refreshed. A cheap HEAD probe (visible tabs only; also fired on tab-focus) flips
7289
+ // the connline within seconds of the user viewing a dead link, and forces a fresh SSE
7290
+ // so recovery (onopen -> clear + flush) still happens when onerror never fired.
7291
+ async function checkHealth(){
7292
+ if (document.hidden) return;
7293
+ let ok = false;
7294
+ try {
7295
+ const c = new AbortController(); const to = setTimeout(() => c.abort(), 3000);
7296
+ const r = await fetch(withKey('/'), { method: 'HEAD', cache: 'no-store', signal: c.signal });
7297
+ clearTimeout(to); ok = r.ok;
7298
+ } catch { ok = false; }
7299
+ if (!ok) {
7300
+ connSetOffline(); // grace-debounced ~800ms
7301
+ if (!_sseReconnectT) { try { _es && _es.close(); } catch {} sseUp = false; esRetryMs = Math.min(esRetryMs, 4000); _sseReconnectT = setTimeout(connectSSE, esRetryMs); }
7302
+ } else if (connOffline && !_sseReconnectT) {
7303
+ forceReconnect(); // server reachable again but the line is still up → reconnect to clear + flush
7304
+ }
7305
+ }
7306
+ function startHealthPoll(){ clearInterval(_healthT); _healthT = setInterval(checkHealth, 4000); }
7073
7307
  function connectSSE(){
7074
- const es = new EventSource(withKey('/api/events'));
7075
- es.onopen = () => { esRetryMs = 1000; sseUp = true; console.log('[manager] sse connected'); refresh(); flushOutbox();
7308
+ _sseReconnectT = null; // this attempt supersedes any scheduled retry
7309
+ const es = _es = new EventSource(withKey('/api/events'));
7310
+ es.onopen = () => { esRetryMs = 1000; sseUp = true; connSetOnline(); console.log('[manager] sse connected'); refresh(); flushOutbox();
7076
7311
  if ($('gearPop').classList.contains('open') && custTab === 'mcp') buildGearPop(); };
7077
7312
  es.onmessage = (e) => {
7078
7313
  const m = JSON.parse(e.data);
7079
7314
  if (m.ev === 'goal') {
7080
- const line = goalLogLine(m.goal, state.goals.find((g) => g.id === m.goal.id));
7315
+ const prev = state.goals.find((g) => g.id === m.goal.id);
7316
+ const line = goalLogLine(m.goal, prev);
7081
7317
  if (line) state.logEntries = pushCapped(state.logEntries, { text: line, ts: Date.now() });
7318
+ // 07-20c: a PR goal reaches 'done' ONLY via a real merge (syncGoalMerges) — so a
7319
+ // review→done flip on a goal that has a PR means the user just merged it on GitHub and
7320
+ // came back. Celebrate (confetti) + a "merged" toast so the merge is unmistakable.
7321
+ if (prev && prev.status === 'review' && m.goal.status === 'done' && m.goal.pr) {
7322
+ try { celebrateApprove(); } catch {}
7323
+ const prNo = (String(m.goal.pr).match(/pull\/(\d+)/) ?? [, ''])[1];
7324
+ showToast(`PR #${prNo} merged — “${(m.goal.text || '').replace(/\s+/g, ' ').slice(0, 48)}” is Done`);
7325
+ }
7082
7326
  upsert(state.goals, m.goal);
7083
7327
  }
7084
7328
  else if (m.ev === 'goal-deleted') state.goals = state.goals.filter((g) => g.id !== m.id);
@@ -7106,13 +7350,19 @@ function connectSSE(){
7106
7350
  scheduleRender();
7107
7351
  };
7108
7352
  es.onerror = () => {
7109
- if (es.readyState === EventSource.CLOSED) {
7110
- es.close();
7111
- if (sseUp) { sseUp = false; if ($('gearPop').classList.contains('open') && custTab === 'mcp') buildGearPop(); }
7112
- console.log(`[manager] sse closed reconnecting in ${esRetryMs}ms`);
7113
- setTimeout(connectSSE, esRetryMs);
7114
- esRetryMs = Math.min(esRetryMs * 2, 15000);
7115
- }
7353
+ // Native EventSource sits silently in CONNECTING and auto-retries on a dropped
7354
+ // or refused connection — readyState only reaches CLOSED on a non-2xx response.
7355
+ // Gating on CLOSED therefore missed every REAL relay/network drop (verified
7356
+ // 2026-07-20: CDP-offline AND killing the server both left the old gate un-fired,
7357
+ // so the red line never showed). Treat ANY error as the live link going down:
7358
+ // stop the native retry (close), flag offline (grace-debounced), own the backoff.
7359
+ if (_sseReconnectT) return; // a reconnect is already scheduled — don't stack
7360
+ try { es.close(); } catch { /* already closed */ }
7361
+ if (sseUp) { sseUp = false; if ($('gearPop').classList.contains('open') && custTab === 'mcp') buildGearPop(); }
7362
+ connSetOffline(); // arm the ~800ms grace timer before the red connline shows
7363
+ console.log(`[manager] sse down (readyState=${es.readyState}) — reconnecting in ${esRetryMs}ms`);
7364
+ _sseReconnectT = setTimeout(connectSSE, esRetryMs);
7365
+ esRetryMs = Math.min(esRetryMs * 2, 15000);
7116
7366
  };
7117
7367
  }
7118
7368
 
@@ -7263,7 +7513,8 @@ async function boot(){
7263
7513
  await refresh();
7264
7514
  flushOutbox();
7265
7515
  connectSSE();
7266
- document.addEventListener('visibilitychange', () => { if (!document.hidden) { refresh(); flushOutbox(); } });
7516
+ startHealthPoll();
7517
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) { refresh(); flushOutbox(); checkHealth(); } });
7267
7518
  // belt and suspenders for the never-lose guarantee: even if every event
7268
7519
  // path misfires, unsent memos are retried on a slow heartbeat
7269
7520
  setInterval(() => { if (outbox.load().some((o) => o.status === 'unsent')) flushOutbox(); }, 15000);
@@ -7770,7 +8021,7 @@ function saEvidenceHtml(kind, factRow, img, filePaths, diffText, gtr, it){
7770
8021
  return `<div class="lg-ev"><div class="lg-evhd">Evidence</div><div class="row">${factRow}</div>${img}</div>`;
7771
8022
  }
7772
8023
  function saItemHtml(it, i){
7773
- const done = { 1: ['ok', '✓', 'Approved'], 2: ['no', '↩', 'Dismissed — re-testing ×3'], 3: ['no', '↩', 'Feedback sent — returned to To Do'], 4: ['ar', '—', 'Archived'], 5: ['no', '↩', 'Reverted — change undone'] }[it.st];
8024
+ const done = { 1: ['ok', '✓', 'Approved'], 2: ['no', '↩', 'Rejected'], 3: ['no', '↩', 'Feedback sent — returned to To Do'], 4: ['ar', '—', 'Archived'], 5: ['no', '↩', 'Reverted — change undone'] }[it.st];
7774
8025
  if (done) {
7775
8026
  // Revert deliberately has NO undo: the PR is closed and its branch deleted —
7776
8027
  // an "UNDO" here would be a lie (HANDOFF-v45 §5.3 honesty over symmetry).
@@ -7839,7 +8090,7 @@ function saItemHtml(it, i){
7839
8090
  <div class="lg-ctx">${contextHtml}<div class="lg-fact"><span class="k">Done when</span><span class="v">${esc(it.check)}</span></div></div>
7840
8091
  ${evidenceHtml}
7841
8092
  ${riskHtml}
7842
- <div class="sa-act"><button class="sa-app" data-saact="app:${i}">${SA_SVG.ok}Approve</button><button class="sa-dis" data-saact="dis:${i}">Dismiss</button><div class="sa-chatwrap"><input class="sa-chat" data-i="${i}" placeholder="What's wrong? Send back to Claude Code…"><button class="sa-send" data-saact="send:${i}" title="Send back"><svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button></div><button class="sa-ic" title="Archive" data-saact="arch:${i}">${SA_SVG.arch}</button><button class="sa-ic" title="Revert" data-saact="rev:${i}">${SA_SVG.trash}</button></div>
8093
+ <div class="sa-act"><button class="sa-app" data-saact="app:${i}">${SA_SVG.ok}Approve</button><button class="sa-dis" data-saact="dis:${i}">Reject</button><div class="sa-chatwrap"><input class="sa-chat" data-i="${i}" placeholder="What's wrong? Send back to Claude Code…"><button class="sa-send" data-saact="send:${i}" title="Send back"><svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button></div><button class="sa-ic" title="Archive" data-saact="arch:${i}">${SA_SVG.arch}</button><button class="sa-ic" title="Revert" data-saact="rev:${i}">${SA_SVG.trash}</button></div>
7843
8094
  </div>`;
7844
8095
  }
7845
8096
  function saRender(){
@@ -7850,7 +8101,7 @@ function saRender(){
7850
8101
  const c = `${pending.length} ready · ${green === pending.length ? 'all green' : `${pending.length - green} failing`} · ${proofN} with proof`;
7851
8102
  const curIdx = pending.findIndex((x) => x.goalId === SA.cur);
7852
8103
  const posLabel = pending.length && curIdx >= 0 ? `${curIdx + 1} / ${pending.length}` : '';
7853
- S.innerHTML = `<div class="sa-panel"><div class="sa-hd"><span class="t">Review</span><span class="c">${c}</span><span class="sa-count">${posLabel}</span><span class="keys">↑↓ MOVE · A APPROVE · D DISMISS · ESC CLOSE</span><button class="sa-x" data-saact="close:0">✕</button></div>
8104
+ S.innerHTML = `<div class="sa-panel"><div class="sa-hd"><span class="t">Review</span><span class="c">${c}</span><span class="sa-count">${posLabel}</span><span class="keys">↑↓ MOVE · A APPROVE · D REJECT · ESC CLOSE</span><button class="sa-x" data-saact="close:0">✕</button></div>
7854
8105
  <div class="sa-body">${SA.items.length ? SA.items.map((it, i) => saItemHtml(it, i)).join('') : '<div class="sa-empty">Nothing waiting for review.</div>'}</div></div>`;
7855
8106
  // Native <details> would collapse on every rebuild (SSE tick, approve, etc.) —
7856
8107
  // track what's open per goal+section in SA.detOpen (persists across re-renders)
@@ -7992,7 +8243,7 @@ async function saAct(it, st, path, body){
7992
8243
  saSync();
7993
8244
  return true;
7994
8245
  }
7995
- const SA_UNDO = { 1: 'revert-review', 2: 'retest-cancel', 3: 'undismiss', 4: 'unarchive' };
8246
+ const SA_UNDO = { 1: 'revert-review', 2: 'reopen', 3: 'undismiss', 4: 'unarchive' }; // 07-20c: st 2 = Reject, undo = reopen
7996
8247
  $('seeall').addEventListener('mousedown', (e) => { if (e.target === $('seeall')) saClose(); }); // no-dim dialog: outside = close
7997
8248
  $('seeall').addEventListener('click', async (e) => {
7998
8249
  const b = e.target.closest('[data-saact]');
@@ -8006,8 +8257,14 @@ $('seeall').addEventListener('click', async (e) => {
8006
8257
  if (k === 'tok') { b.classList.toggle('open'); $('seeall').querySelector(`#tokdetail${i}`)?.classList.toggle('open'); return; }
8007
8258
  if (k === 'runlocal') { $('seeall').querySelector(`#runlocal${i}`)?.classList.toggle('open'); return; }
8008
8259
  // every verdict auto-advances the focus to the next pending card (tempo).
8009
- if (k === 'app') { const bx = b.getBoundingClientRect(); confettiBurst(bx.left + bx.width / 2, bx.top + bx.height / 2); return saAct(it, 1, 'approve').then((ok) => { if (ok) saAdvanceFocus(it.goalId); }); }
8010
- if (k === 'dis') return saAct(it, 2, 'retest').then((ok) => { if (ok) saAdvanceFocus(it.goalId); });
8260
+ if (k === 'app') { const bx = b.getBoundingClientRect(); return saAct(it, 1, 'approve').then((ok) => {
8261
+ if (!ok) return;
8262
+ // 07-20c: if the goal has a PR it stays in review (needsMerge) — open the merge dialog
8263
+ // instead of celebrating/advancing; saCall already upserted the still-'review' goal.
8264
+ const g = state.goals.find((x) => x.id === it.goalId);
8265
+ if (g && g.status === 'review' && g.pr) { confirmMerge(g); return; }
8266
+ confettiBurst(bx.left + bx.width / 2, bx.top + bx.height / 2); saAdvanceFocus(it.goalId); }); }
8267
+ if (k === 'dis') return saAct(it, 2, 'reject').then((ok) => { if (ok) saAdvanceFocus(it.goalId); }); // 07-20c: Reject → Rejected column
8011
8268
  if (k === 'arch') return saAct(it, 4, 'archive').then((ok) => { if (ok) saAdvanceFocus(it.goalId); });
8012
8269
  if (k === 'rev') return saAct(it, 5, 'revert').then((ok) => { if (ok) saAdvanceFocus(it.goalId); });
8013
8270
  if (k === 'send') {
@@ -8051,11 +8308,16 @@ document.addEventListener('keydown', (e) => {
8051
8308
  e.preventDefault();
8052
8309
  const gid = it.goalId;
8053
8310
  if (e.key === 'a' || e.key === 'A') {
8054
- const el = $('seeall').querySelector(`.sa-item[data-gid="${gid}"] .sa-app`);
8055
- const box = el?.getBoundingClientRect();
8056
- confettiBurst(box ? box.left + box.width / 2 : innerWidth / 2, box ? box.top + box.height / 2 : innerHeight / 3);
8057
- saAct(it, 1, 'approve').then((ok) => { if (ok) saAdvanceFocus(gid); });
8058
- } else saAct(it, 2, 'retest').then((ok) => { if (ok) saAdvanceFocus(gid); });
8311
+ saAct(it, 1, 'approve').then((ok) => {
8312
+ if (!ok) return;
8313
+ const g = state.goals.find((x) => x.id === gid);
8314
+ if (g && g.status === 'review' && g.pr) { confirmMerge(g); return; } // 07-20c: PR goal → merge dialog
8315
+ const el = $('seeall').querySelector(`.sa-item[data-gid="${gid}"] .sa-app`);
8316
+ const box = el?.getBoundingClientRect();
8317
+ confettiBurst(box ? box.left + box.width / 2 : innerWidth / 2, box ? box.top + box.height / 2 : innerHeight / 3);
8318
+ saAdvanceFocus(gid);
8319
+ });
8320
+ } else saAct(it, 2, 'reject').then((ok) => { if (ok) saAdvanceFocus(gid); }); // 07-20c: D = Reject
8059
8321
  });
8060
8322
  // 紙吹雪 (Approveの気持ちよさ=機能要件・PRD §5.1) — flagship confetti port:
8061
8323
  // 6 full-spectrum colours, ~90 small rects, gravity, gone in 1.4s.
@@ -8364,7 +8626,7 @@ let _delConfirmResolve = null;
8364
8626
  // Discard positioned left); dismissing the dialog must still land on the non-destructive choice
8365
8627
  // regardless of which label happens to sit in which slot.
8366
8628
  let _delDismissValue = false;
8367
- function confirmDelete({ title, body, preview, confirmLabel, cancelLabel, dismissValue = false, confirmDanger = true, cancelDanger = false } = {}){
8629
+ function confirmDelete({ title, body, preview, previewHtml, previewPlain = false, confirmLabel, cancelLabel, dismissValue = false, confirmDanger = true, cancelDanger = false } = {}){
8368
8630
  $('delModalTitle').textContent = title || 'Delete this goal?';
8369
8631
  $('delModalBody').textContent = body || "This can't be undone.";
8370
8632
  $('delConfirmBtn').textContent = confirmLabel || 'Delete';
@@ -8376,7 +8638,10 @@ function confirmDelete({ title, body, preview, confirmLabel, cancelLabel, dismis
8376
8638
  $('delConfirmBtn').className = 'mbtn' + (confirmDanger ? ' danger' : ' primary');
8377
8639
  $('delCancelBtn').className = 'mbtn' + (cancelDanger ? ' danger' : ' ghost');
8378
8640
  const pv = $('delPreviewTitle');
8379
- if (preview) { pv.textContent = preview; pv.style.display = ''; } else { pv.textContent = ''; pv.style.display = 'none'; }
8641
+ pv.classList.toggle('plain', !!previewPlain);
8642
+ if (previewHtml) { pv.innerHTML = previewHtml; pv.style.display = ''; }
8643
+ else if (preview) { pv.textContent = preview; pv.style.display = ''; }
8644
+ else { pv.textContent = ''; pv.style.display = 'none'; }
8380
8645
  _delDismissValue = dismissValue;
8381
8646
  $('deleteConfirmOverlay').classList.add('show');
8382
8647
  return new Promise((resolve) => { _delConfirmResolve = resolve; });
@@ -9113,7 +9378,6 @@ function gearSetHTML(){
9113
9378
  // to anonymous free users too. Sign-in is an extra (sync across devices), not a
9114
9379
  // gate to see your own usage or upgrade.
9115
9380
  const freeUsage = `<div class="srow"><div class="sl">Plan</div><span class="bx-sval">Free</span></div>
9116
- <div class="srow"><div class="sl">To Dos<span class="sd">pending, this install</span></div><span class="bx-sval bx-num">${u.pending ?? 0} / ${lim.pending ?? 5}</span></div>
9117
9381
  <div class="srow"><div class="sl">Lifetime</div><span class="bx-sval bx-num">${u.cumulative ?? 0} / ${lim.cumulative ?? 20}</span></div>
9118
9382
  <button class="bx-cta bx-up-set" data-gupgrade="1"><span class="bx-spin"></span>Upgrade${priceTag(bill.price)}</button>`;
9119
9383
  const billingRow = bill.plan === 'paid'