@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 +330 -66
- package/engine/lib.mjs +103 -12
- package/engine/server.mjs +199 -31
- package/package.json +1 -1
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">
|
|
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">
|
|
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:
|
|
3311
|
-
//
|
|
3312
|
-
//
|
|
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?.
|
|
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
|
-
|
|
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
|
-
|
|
4000
|
+
// mirror unsentMetaLabel({online}) from engine/lib.mjs — offline 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
|
-
|
|
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
|
|
4303
|
-
|
|
4304
|
-
|
|
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}/
|
|
4309
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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 =
|
|
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 ${
|
|
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
|
-
|
|
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 =
|
|
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
|
|
6018
|
-
|
|
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
|
-
|
|
6021
|
-
|
|
6022
|
-
if (r && r.ok) await refresh(); else showErr('
|
|
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
|
-
${
|
|
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 = () => {
|
|
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
|
-
|
|
7075
|
-
es
|
|
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
|
|
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
|
-
|
|
7110
|
-
|
|
7111
|
-
|
|
7112
|
-
|
|
7113
|
-
|
|
7114
|
-
|
|
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
|
-
|
|
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', '↩', '
|
|
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}">
|
|
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
|
|
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: '
|
|
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();
|
|
8010
|
-
|
|
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
|
-
|
|
8055
|
-
|
|
8056
|
-
|
|
8057
|
-
|
|
8058
|
-
|
|
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
|
-
|
|
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'
|