@galda/cli 0.10.36 → 0.10.38
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 +717 -107
- package/engine/lib.mjs +87 -12
- package/engine/server.mjs +61 -16
- 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. */
|
|
@@ -1930,12 +1948,180 @@
|
|
|
1930
1948
|
.sa-done .undo{margin-left:auto;border:0;background:none;color:var(--ink3);font:500 10px/1 var(--mono);cursor:pointer}
|
|
1931
1949
|
.sa-done .undo:hover{color:var(--ink);background:transparent}
|
|
1932
1950
|
.sa-empty{font:500 11px/1 var(--mono);color:var(--ink3);padding:20px 0}
|
|
1951
|
+
/* ═══ Review card redesign (REVIEW-CARD-SPEC.md · Masa 2026-07-20 confirmed):
|
|
1952
|
+
Linear-docs typography · META = green outline pill (Chip) · GitHub diff = Bright.
|
|
1953
|
+
Ported from the flagship redesign (fs-redesign.css), the dev-only PLAN/META/DIFF
|
|
1954
|
+
switchers resolved to the confirmed defaults plan=d / meta=a / gh=d, and the
|
|
1955
|
+
flagship fallback hexes mapped to the app's real theme tokens (theme.css).
|
|
1956
|
+
Scoped to .sa-item.redesign so the plain .sa-done one-liner is untouched. ═══ */
|
|
1957
|
+
#seeall .sa-panel{width:min(940px,94%);max-width:940px;backdrop-filter:blur(30px) saturate(1.4);-webkit-backdrop-filter:blur(30px) saturate(1.4)}
|
|
1958
|
+
/* body/header/card geometry ported verbatim from the artifact (fs-redesign.css plan=d):
|
|
1959
|
+
the CARD carries the 40px side gutters + 28/34 top/bottom, the header matches (40/34),
|
|
1960
|
+
and the See-all body drops its own gutter/gap/centering so the card padding is the
|
|
1961
|
+
single source of the Linear-docs air. !important because the app's own .sa-item /
|
|
1962
|
+
.sa-body / .lg rules (added first, same-or-higher specificity) otherwise win. */
|
|
1963
|
+
#seeall .sa-body:has(.sa-item.redesign){gap:0 !important;align-items:stretch !important}
|
|
1964
|
+
#seeall .sa-panel .sa-hd:has(~ .sa-body .sa-item.redesign){padding:16px 34px 12px 40px !important}
|
|
1965
|
+
.sa-item.redesign{width:100% !important;background:none !important;border:0 !important;border-radius:0 !important;box-shadow:none !important;
|
|
1966
|
+
padding:28px 40px 34px !important;font-family:var(--ui);color:var(--ink);line-height:1.6}
|
|
1967
|
+
.sa-item.redesign *{box-sizing:border-box}
|
|
1968
|
+
/* first card: no extra top margin — the card's own 28px padding-top (plus the
|
|
1969
|
+
body's 16px) is the top air, matching the ~40px inter-card rhythm below.
|
|
1970
|
+
The old 52px pushed the header→#N gap to ~96px of dead whitespace (Masa 2026-07-20). */
|
|
1971
|
+
#seeall .sa-body>.sa-item.redesign:first-child{margin-top:0 !important}
|
|
1972
|
+
#seeall .sa-body>.sa-item.redesign+.sa-item.redesign{border-top:1px solid var(--hair) !important;margin-top:0 !important;padding-top:40px !important}
|
|
1973
|
+
/* reply composer: match the flagship saItem field (34px tall · radius 10 · 12px left pad)
|
|
1974
|
+
so it doesn't read cramped next to the 32px Approve/Dismiss (Masa 2026-07-20).
|
|
1975
|
+
padding-right (42px for the send ring) stays from .sa-chatwrap .sa-chat. */
|
|
1976
|
+
#seeall .sa-chat{height:34px;border-radius:10px;padding-left:12px}
|
|
1977
|
+
/* header — mono eyebrow · review dot · In review */
|
|
1978
|
+
.redesign .rhead{display:flex;align-items:center;gap:12px;font-family:var(--mono);font-size:11px;color:var(--ink3)}
|
|
1979
|
+
.redesign .rtag{letter-spacing:.02em;font-variant-numeric:tabular-nums}
|
|
1980
|
+
.redesign .rst{display:inline-flex;align-items:center;gap:6px;color:var(--ink2)}
|
|
1981
|
+
.redesign .rdot{width:6px;height:6px;border-radius:50%;background:var(--amber,#e0a021);display:inline-block}
|
|
1982
|
+
.redesign .rtitle{font-family:var(--ui);font-size:18px;font-weight:680;letter-spacing:-.018em;line-height:1.3;margin:8px 0 0 !important;color:var(--ink)}
|
|
1983
|
+
/* meta — Linear sans, green/neutral chips, hairline under (spec §4) */
|
|
1984
|
+
.redesign .rmeta{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;margin:12px 0 0 !important;padding-bottom:12px !important;
|
|
1985
|
+
border-bottom:1px solid var(--hair);font-family:var(--ui);font-size:12px;letter-spacing:-.004em;color:var(--ink2)}
|
|
1986
|
+
.redesign .rmeta .mx{display:inline-flex;align-items:center;gap:5px}
|
|
1987
|
+
.redesign .rmeta .mk{color:var(--ink3)}
|
|
1988
|
+
.redesign .rmeta b{color:var(--ink);font-weight:600}
|
|
1989
|
+
.redesign .rmeta .mmut{color:var(--ink3);font-weight:400}
|
|
1990
|
+
.redesign .rmeta .ar{font-size:.82em;margin-left:1px;color:var(--ink3)}
|
|
1991
|
+
.redesign .rmeta .a{color:#3fd07a;font-style:normal} .redesign .rmeta .d{color:#ff7b72;font-style:normal}
|
|
1992
|
+
/* Chip = outline pill (meta=a): green for PR/Token, neutral for Local */
|
|
1993
|
+
.redesign .rmeta .mlink{cursor:pointer;padding:3px 10px;border-radius:999px;
|
|
1994
|
+
border:1px solid color-mix(in srgb,currentColor 40%,var(--hair));text-decoration:none;background:none;font:inherit}
|
|
1995
|
+
.redesign .rmeta .mlink:hover{background:color-mix(in srgb,currentColor 9%,transparent)}
|
|
1996
|
+
.redesign .rmeta .mlink.pr,.redesign .rmeta .mlink.pr *{color:var(--green)}
|
|
1997
|
+
/* whole PR chip bold, not just the number (REVIEW-CARD-SPEC §5) — scoped to .mlink.pr so Token/Local chips are unaffected */
|
|
1998
|
+
.redesign .rmeta .mlink.pr,.redesign .rmeta .mlink.pr .mk,.redesign .rmeta .mlink.pr b,.redesign .rmeta .mlink.pr .mmut{font-weight:700}
|
|
1999
|
+
.redesign .rmeta .mlink:not(.pr){color:var(--ink2)}
|
|
2000
|
+
.redesign .rmeta .mlink:not(.pr) .mk{color:var(--ink3)}
|
|
2001
|
+
.redesign .rmeta .mlink:not(.pr) b{color:var(--ink)}
|
|
2002
|
+
.redesign .rmeta .mtok{cursor:pointer;padding:3px 10px;border-radius:999px;font:inherit;
|
|
2003
|
+
border:1px solid color-mix(in srgb,var(--green) 40%,var(--hair));background:none}
|
|
2004
|
+
.redesign .rmeta .mtok,.redesign .rmeta .mtok *{color:var(--green)}
|
|
2005
|
+
.redesign .rmeta .mtok b{font-weight:700}
|
|
2006
|
+
.redesign .rmeta .ok{color:var(--green)}
|
|
2007
|
+
.redesign .rmeta .tokchevron{font-style:normal;font-size:11px;line-height:1;transition:transform .2s;opacity:.8}
|
|
2008
|
+
.redesign .rmeta .mtok.open .tokchevron{transform:rotate(90deg)}
|
|
2009
|
+
.redesign .rmeta-detail{display:none;margin:12px 0 0;padding:9px 12px;background:color-mix(in srgb,var(--ink) 4%,transparent);border-radius:8px}
|
|
2010
|
+
.redesign .rmeta-detail.open{display:block}
|
|
2011
|
+
.redesign .rmeta-detail .k{font:600 8px/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--ink3);margin-bottom:5px;display:block}
|
|
2012
|
+
.redesign .rmeta-detail .v{font:500 11.5px/1.6 var(--ui);color:var(--ink2)}
|
|
2013
|
+
/* sections — Linear-docs bold sentence-case headings + high-contrast body.
|
|
2014
|
+
margins are !important: the app's pre-existing .lg / .sa-item rules load first and
|
|
2015
|
+
otherwise tie/beat these on specificity, flattening the Linear-docs rhythm. */
|
|
2016
|
+
.redesign .rsec{margin-top:24px !important}
|
|
2017
|
+
.redesign .rsec-h{font-family:var(--ui);font-size:15px;font-weight:680;letter-spacing:-.012em;line-height:1.3;color:var(--ink);margin:0 0 9px !important}
|
|
2018
|
+
/* body prose = the app's normal text: 14px / −.012em, matching .bubble (Masa 2026-07-20:
|
|
2019
|
+
13px read thinner/lighter for Japanese than the surrounding app). §2. */
|
|
2020
|
+
.redesign .rbody{font-size:14px;line-height:1.62;letter-spacing:-.012em;color:var(--ink);max-width:70ch;margin:0 !important}
|
|
2021
|
+
.redesign .rdesc,.redesign .rrisk{color:var(--ink2)}
|
|
2022
|
+
.redesign .rfiles{margin:12px 0 0;font-family:var(--mono);font-size:12px;color:var(--ink2);line-height:1.75}
|
|
2023
|
+
.redesign .rfiles .a{color:#3fd07a;font-style:normal}.redesign .rfiles .d{color:#ff7b72;font-style:normal}
|
|
2024
|
+
/* Request accordion (spec §3) — chevron › rotate 90, body indented, no left rule */
|
|
2025
|
+
.redesign .rreq .rsec-h{margin-bottom:6px !important}
|
|
2026
|
+
.redesign .reqacc{margin-top:0 !important}
|
|
2027
|
+
.redesign .racc>summary{list-style:none;cursor:pointer;display:flex;gap:10px;align-items:baseline}
|
|
2028
|
+
.redesign .racc>summary::-webkit-details-marker{display:none}
|
|
2029
|
+
.redesign .racc>summary::before{content:"›";font-size:1.05em;line-height:1;color:var(--ink2);display:inline-block;transition:transform .14s}
|
|
2030
|
+
.redesign .racc[open]>summary::before{transform:rotate(90deg)}
|
|
2031
|
+
.redesign .reqacc>summary{color:var(--ink);font-size:13.5px;line-height:1.55}
|
|
2032
|
+
.redesign .reqbody{margin:14px 0 0 !important;padding-left:22px !important}
|
|
2033
|
+
.redesign .rsub{font-family:var(--ui);font-size:11px;font-weight:600;color:var(--ink3);margin:16px 0 8px !important}
|
|
2034
|
+
.redesign .reqbody>.rsub:first-child{margin-top:2px !important}
|
|
2035
|
+
.redesign .reqbody .lg-bullets{margin:0;padding:0 0 0 16px;font-size:13px;line-height:1.65;color:var(--ink2)}
|
|
2036
|
+
.redesign .reqbody .lg-bullets li{margin-bottom:5px}
|
|
2037
|
+
.redesign .reqbody .lg-splitnote{background:color-mix(in srgb,var(--ink) 4%,transparent);border-radius:8px;padding:10px 12px;margin-top:12px}
|
|
2038
|
+
.redesign .reqbody .lg-splitnote .hd{font:600 8.5px/1 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--ink3);margin-bottom:7px}
|
|
2039
|
+
.redesign .reqbody .lg-splitnote ul{margin:0;padding:0;list-style:none;font-size:12px;line-height:1.8;color:var(--ink3)}
|
|
2040
|
+
.redesign .reqbody .lg-splitnote li.cur{color:var(--ink)} .redesign .reqbody .lg-splitnote li.cur b{color:var(--green);font-weight:600}
|
|
2041
|
+
/* workflow (spec §8) — vertical ✓ green / → green / □ dim */
|
|
2042
|
+
.redesign .rwf{display:flex;flex-direction:column;gap:2px;margin:10px 0 0 !important;font-family:var(--ui);font-size:12px;letter-spacing:-.004em;color:var(--ink3)}
|
|
2043
|
+
.redesign .rwf .wf-s{display:flex;align-items:center;gap:9px}
|
|
2044
|
+
.redesign .rwf .wf-s .mk{width:12px;text-align:center;flex:0 0 12px}
|
|
2045
|
+
.redesign .rwf .wf-s.ok{color:var(--ink2)}.redesign .rwf .wf-s.ok .mk{color:var(--green)}
|
|
2046
|
+
.redesign .rwf .wf-s.now{color:var(--ink);font-weight:600}.redesign .rwf .wf-s.now .mk{color:var(--green);font-weight:700}
|
|
2047
|
+
.redesign .rwf .wf-s.todo{color:var(--ink3)}.redesign .rwf .wf-s.todo .mk{color:var(--ink3);font-size:.82em}
|
|
2048
|
+
/* proof (spec §6) — before/after forced same size · run-log · honest missing note */
|
|
2049
|
+
.redesign .beforeafter{display:grid;grid-template-columns:1fr 1fr;gap:12px;max-width:640px;align-items:start}
|
|
2050
|
+
.redesign .ba-col{display:flex;flex-direction:column;gap:6px;min-width:0}
|
|
2051
|
+
.redesign .balabel{font-family:var(--mono);font-size:11px;letter-spacing:.04em;text-transform:uppercase;color:var(--ink3)}
|
|
2052
|
+
.redesign .proofimg{display:block;width:100%;height:auto;max-height:none;object-fit:contain;border:1px solid var(--hair);border-radius:9px;cursor:zoom-in;margin:0}
|
|
2053
|
+
.redesign .rproof{max-width:640px}
|
|
2054
|
+
/* screen-recording player (spec §6) — sits under before/after, same 640px column */
|
|
2055
|
+
.redesign .rvid{margin:12px 0 0;max-width:640px}
|
|
2056
|
+
.redesign .rvid svg,.redesign .rvid .proofvid{display:block;width:100%;height:auto;border:1px solid var(--hair);border-radius:9px;background:#000}
|
|
2057
|
+
.redesign .rvid .proofvid{aspect-ratio:16/9;object-fit:contain}
|
|
2058
|
+
.redesign .rvid figcaption{margin-top:7px;font-family:var(--mono);font-size:11px;letter-spacing:.02em;color:var(--ink3)}
|
|
2059
|
+
.redesign .rrun{border:1px solid var(--hair);border-radius:10px;overflow:hidden;font-family:var(--mono);font-size:12.5px;max-width:640px}
|
|
2060
|
+
.redesign .rrun .rr{display:flex;justify-content:space-between;gap:16px;padding:10px 14px;border-top:1px solid var(--hair)}
|
|
2061
|
+
.redesign .rrun .rr:first-child{border-top:0}
|
|
2062
|
+
.redesign .rrun .rr .lab{color:var(--ink2)}.redesign .rrun .rr .v{color:var(--green);text-align:right}
|
|
2063
|
+
.redesign .rmiss{display:flex;align-items:flex-start;gap:8px;font-size:12.5px;line-height:1.6;color:var(--ink2)}
|
|
2064
|
+
.redesign .rmiss svg{width:15px;height:15px;flex:0 0 15px;margin-top:2px;fill:none;stroke:var(--amber);stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
|
2065
|
+
/* GitHub diff = Bright (gh=d): old|new gutters · +/− column · line+gutter tint */
|
|
2066
|
+
.redesign .rdiff2{margin:12px 0 0;border:1px solid var(--hair);border-radius:10px;overflow:hidden;font-family:var(--mono);font-size:12px;line-height:1.6}
|
|
2067
|
+
.redesign .ghl{display:grid;grid-template-columns:46px 46px 22px 1fr}
|
|
2068
|
+
.redesign .ghl .gho,.redesign .ghl .ghn{color:var(--ink3);opacity:.6;text-align:right;padding:1px 8px;user-select:none;-webkit-user-select:none;font-variant-numeric:tabular-nums}
|
|
2069
|
+
.redesign .ghl .ghm{text-align:center;color:var(--ink3);padding:1px 0}
|
|
2070
|
+
.redesign .ghl .ghc{padding:1px 10px;white-space:pre-wrap;color:var(--ink)}
|
|
2071
|
+
.redesign .gh-add{background:rgba(46,204,113,.22)}
|
|
2072
|
+
.redesign .gh-add .gho,.redesign .gh-add .ghn{background:rgba(46,204,113,.30)}
|
|
2073
|
+
.redesign .gh-add .ghm{color:#3fb950}
|
|
2074
|
+
.redesign .gh-del{background:rgba(248,81,73,.20)}
|
|
2075
|
+
.redesign .gh-del .gho,.redesign .gh-del .ghn{background:rgba(248,81,73,.30)}
|
|
2076
|
+
.redesign .gh-del .ghm{color:#f85149}
|
|
2077
|
+
.redesign .gh-hi{border-radius:2px;padding:0 1px}
|
|
2078
|
+
.redesign .gh-add .gh-hi{background:rgba(46,204,113,.55)} .redesign .gh-del .gh-hi{background:rgba(248,81,73,.5)}
|
|
2079
|
+
/* huge-diff footer (spec §7): "…+N 行 · GitHub で開く ↗" */
|
|
2080
|
+
.redesign .ghfoot{margin-top:8px;font-family:var(--mono);font-size:11.5px;color:var(--ink3);display:flex;gap:2px;align-items:center;flex-wrap:wrap}
|
|
2081
|
+
.redesign .ghfoot .ghopen{color:var(--ink2);text-decoration:none}
|
|
2082
|
+
.redesign .ghfoot .ghopen:hover{color:var(--blue)}
|
|
2083
|
+
/* meta links carry NO underline — PR is a real <a href> (spec §5, Masa 2026-07-20) */
|
|
2084
|
+
.redesign .rmeta a{text-decoration:none}
|
|
2085
|
+
/* Request / What-to-check = full contrast, same ink as the title (spec §2, no fade) */
|
|
2086
|
+
.redesign .rask,.redesign .rcheck,.redesign .rcheck li,.redesign .rcheck1{color:var(--ink) !important}
|
|
2087
|
+
.redesign ul.rcheck{margin:2px 0 0;padding:0 0 0 18px;font-size:14px;line-height:1.7;letter-spacing:-.012em}
|
|
2088
|
+
.redesign ul.rcheck li{margin-bottom:6px}
|
|
2089
|
+
/* Activity (spec §3) = ONE Linear-docs pulldown. Heading plain; chevron on the FIRST
|
|
2090
|
+
line; tapping opens the rest below, offset-indented (no left rule); Discussion folds
|
|
2091
|
+
in at the bottom of the opened body. */
|
|
2092
|
+
.redesign .ract{margin-top:24px !important}
|
|
2093
|
+
.redesign .ract>.rsec-h{margin-bottom:10px !important}
|
|
2094
|
+
.redesign .lacc>summary{list-style:none;cursor:pointer;display:flex;gap:9px;align-items:baseline}
|
|
2095
|
+
.redesign .lacc>summary::-webkit-details-marker{display:none}
|
|
2096
|
+
.redesign .lacc .ltog{flex:0 0 auto;color:var(--ink3);font-style:normal;line-height:1;transition:transform .14s}
|
|
2097
|
+
.redesign .lacc[open]>summary .ltog{transform:rotate(90deg)}
|
|
2098
|
+
.redesign .lacc .lfirst{flex:1;min-width:0}
|
|
2099
|
+
.redesign .lacc .lbody{margin-top:12px;padding-left:18px}
|
|
2100
|
+
/* transcript rows — Linear-clean, borderless (You / tool mono / res green / Claude) */
|
|
2101
|
+
.redesign .rtx{border:0;background:none}
|
|
2102
|
+
.redesign .txr{display:flex;gap:12px;padding:5px 0;font-size:13px;line-height:1.55}
|
|
2103
|
+
.redesign .txr .w{flex:0 0 46px;font-size:11px;color:var(--ink3);padding-top:1px}
|
|
2104
|
+
.redesign .txr.you .w,.redesign .txr.cl .w{color:var(--ink);font-weight:600}
|
|
2105
|
+
.redesign .txr .m{flex:1;min-width:0;color:var(--ink2)}
|
|
2106
|
+
.redesign .txr.cl .m{color:var(--ink)}
|
|
2107
|
+
.redesign .txr.tool .m{color:var(--ink2);font-family:var(--mono);font-size:12px}
|
|
2108
|
+
.redesign .txr.tool .m b{color:var(--ink);font-weight:600}
|
|
2109
|
+
.redesign .txr.res .m{font-family:var(--mono);font-size:12px;color:var(--green)}
|
|
2110
|
+
/* Discussion (review back-and-forth) folded at the bottom of Activity's body */
|
|
2111
|
+
.redesign .lbody .rthread{display:flex;flex-direction:column;gap:16px;margin:0}
|
|
2112
|
+
.redesign .lbody .rmsg .who{font-size:12px;font-weight:600;color:var(--ink);margin-bottom:4px}
|
|
2113
|
+
.redesign .lbody .rmsg .msg{font-size:13px;line-height:1.55;color:var(--ink2)}
|
|
2114
|
+
/* sensitive-area note (real) sits below What changed */
|
|
2115
|
+
.redesign .lg-risk{margin-top:20px}
|
|
1933
2116
|
#saconf{position:fixed;inset:0;pointer-events:none;z-index:99;display:block}
|
|
1934
2117
|
/* retest ×3 note surfaces on the lane review card too (v45 §5.3) */
|
|
1935
2118
|
#fsRoot .rcard .rnote{display:block;font:500 9.5px/1.4 var(--mono);color:var(--green);margin-top:3px}
|
|
1936
2119
|
/* To Do timeline: order via tone + labels, no axis line (§1.5-4) */
|
|
1937
2120
|
#fsRoot .tl{position:relative}
|
|
1938
2121
|
#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) */
|
|
2122
|
+
/* 07-20c: Rejected reads as a real section heading (like Review), not the tiny mono lane captions */
|
|
2123
|
+
#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}
|
|
2124
|
+
#fsRoot .tlcap.rejcap .num{font-size:10px;color:var(--ink3)}
|
|
1939
2125
|
#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. */
|
|
1940
2126
|
#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) */
|
|
1941
2127
|
#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 */
|
|
@@ -1951,6 +2137,7 @@
|
|
|
1951
2137
|
#fsRoot .trow .stword.int{color:var(--amber)}
|
|
1952
2138
|
#fsRoot .trow .st.ok{border-color:transparent;background:rgba(var(--green-rgb),.12);position:relative}
|
|
1953
2139
|
#fsRoot .trow .st.ok::after{content:"✓";position:absolute;inset:0;display:grid;place-items:center;font-size:8px;color:var(--green)}
|
|
2140
|
+
#fsRoot .trow .st.rej{border-color:rgba(var(--danger-rgb),.5)} /* 07-20c: rejected — a quiet danger dot (settled, not alarming) */
|
|
1954
2141
|
#fsRoot .trow .tt{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
1955
2142
|
#fsRoot .trow .num{font-size:10px;color:var(--ink3);flex:0 0 auto}
|
|
1956
2143
|
#fsRoot .trow .pct{font-size:10.5px;color:var(--green);flex:0 0 auto;font-family:var(--mono);font-variant-numeric:tabular-nums}
|
|
@@ -2812,7 +2999,7 @@
|
|
|
2812
2999
|
<div class="reqlist" id="reviewList"></div>
|
|
2813
3000
|
<div class="revactions">
|
|
2814
3001
|
<button type="button" class="reqbtn approve" id="reviewApprove">Approve goal</button>
|
|
2815
|
-
<button type="button" class="reqbtn dismiss" id="reviewDismiss">Dismiss
|
|
3002
|
+
<button type="button" class="reqbtn dismiss" id="reviewDismiss">Dismiss</button>
|
|
2816
3003
|
<span style="flex:1"></span>
|
|
2817
3004
|
<button class="mbtn ghost" id="reviewClose">Close</button>
|
|
2818
3005
|
</div>
|
|
@@ -2967,7 +3154,7 @@ const savedTheme = (() => {
|
|
|
2967
3154
|
if (v !== s) localStorage.setItem('theme', v);
|
|
2968
3155
|
return v;
|
|
2969
3156
|
})();
|
|
2970
|
-
const state = { projects: [], goals: [], tasks: [], act: {}, actAt: {}, todos: {}, chatTurns: {}, active: '
|
|
3157
|
+
const state = { projects: [], goals: [], tasks: [], act: {}, actAt: {}, todos: {}, chatTurns: {}, active: 'default', editing: null, attach: [], connectCommand: '', models: ['sonnet'], agentModels: null, agentEfforts: null, agents: ['claude-code', 'codex'], expanded: new Set(), openThreads: new Set(), queueOrder: {}, externalActivity: [], selectedGoal: null, workflowColumns: {}, wfDraft: null, reviewDefinitions: {}, rdDraft: null, connectedApp: localStorage.getItem('sel:agent') || 'claude-code', reviewOpenGoal: null, reviewApprovals: {}, goalDetailOpen: null, qreplyOpen: new Set(), logCollapsed: localStorage.getItem('logCollapsed') !== '0', logEntries: [], layout: localStorage.getItem('layout') || 'flagship', theme: savedTheme, boardskin: localStorage.getItem('boardskin') || 'auto', summaryPattern: Number(localStorage.getItem('summaryPattern')) || 3, peraSpot: 0, skill: null, skillsCache: null,
|
|
2971
3158
|
// per-message send overrides set via slash-commands (reset after each send,
|
|
2972
3159
|
// except model which is sticky in localStorage). palette = open command list.
|
|
2973
3160
|
msg: { mode: null, model: null, effort: null, skill: null, later: false, review: false },
|
|
@@ -3000,6 +3187,7 @@ function restoreFocusedAnswerInput(container, attr, saved){
|
|
|
3000
3187
|
const STATUS_LABEL = {
|
|
3001
3188
|
sending: 'Sending…', stacked: 'Queued', planning: 'Planning…', queued: 'To do', running: 'Working…', needsInput: 'Needs input',
|
|
3002
3189
|
review: 'In review', done: 'Done', failed: 'Failed', partial: 'Partly failed', interrupted: 'Interrupted', skipped: 'Skipped',
|
|
3190
|
+
rejected: 'Dismissed',
|
|
3003
3191
|
};
|
|
3004
3192
|
// Mirrors engine/lib.mjs goalChip() — kept in sync manually since this
|
|
3005
3193
|
// inline script can't import server-side code. `columns` (a project's
|
|
@@ -3018,6 +3206,7 @@ const GOAL_CHIP = (s, columns, startedAt) => s === 'stacked' || s === 'sending'
|
|
|
3018
3206
|
s === 'done' ? [pickColumnLabel(columns, 'done', 'DONE'), ''] :
|
|
3019
3207
|
s === 'retesting' ? ['RE-TESTING', 'doing'] :
|
|
3020
3208
|
s === 'reverted' ? ['REVERTED', ''] :
|
|
3209
|
+
s === 'rejected' ? ['DISMISSED', 'rejected'] :
|
|
3021
3210
|
s === 'folded' ? ['FOLDED', ''] :
|
|
3022
3211
|
s === 'blocked' ? ['BLOCKED', 'blocked'] : s === 'needsInput' ? ['NEEDS INPUT', 'blocked'] : ['FAIL', ''];
|
|
3023
3212
|
const FINISHED = (s) => ['done', 'partial', 'failed', 'interrupted'].includes(s);
|
|
@@ -3321,11 +3510,17 @@ function buildReviewDigest({ goals, tasks } = {}){
|
|
|
3321
3510
|
}
|
|
3322
3511
|
return rows;
|
|
3323
3512
|
}
|
|
3324
|
-
// Review title:
|
|
3325
|
-
//
|
|
3326
|
-
//
|
|
3513
|
+
// Review title: the same goal title the list view's goal header shows
|
|
3514
|
+
// (goalHeadText = g.text ?? g.plan[0]) so a card in "See all" and the same goal
|
|
3515
|
+
// in the list read identically (Masa 2026-07-19). Text-first; reviewSummary.changed
|
|
3516
|
+
// stays only as a last resort for a goal with no text. Pulled into one place so the
|
|
3517
|
+
// review card's own title and its sibling-goal titles (H23 split note) stay consistent.
|
|
3327
3518
|
function saReviewTitle(g){
|
|
3328
|
-
|
|
3519
|
+
// The goal's short ticket NAME wins everywhere it's shown (See-all title, Review-list
|
|
3520
|
+
// rail, board, sibling list) so the title reads the same in every place and never
|
|
3521
|
+
// repeats the full request (Masa 2026-07-21, spec §4). Real goals have no
|
|
3522
|
+
// reviewSummary.title, so they fall back to the request text unchanged (no regression).
|
|
3523
|
+
return String(g?.reviewSummary?.title || g?.text || g?.plan?.[0] || g?.reviewSummary?.changed || '').replace(/\s+/g, ' ').trim().slice(0, 90);
|
|
3329
3524
|
}
|
|
3330
3525
|
// H23 (2026-07-18) evidence-kind detection. Mirrors engine/lib.mjs isUiChange() /
|
|
3331
3526
|
// hasTestRelevantChanges() — kept in sync manually since the browser script isn't a
|
|
@@ -3955,21 +4150,35 @@ function renderQueue(){
|
|
|
3955
4150
|
const stacked = state.goals.filter((g) => g.projectId === state.active && g.status === 'stacked')
|
|
3956
4151
|
.sort((a, b) => (b.prio ?? 0) - (a.prio ?? 0) || a.id - b.id);
|
|
3957
4152
|
const planning = state.goals.filter((g) => g.projectId === state.active && ['sending', 'planning'].includes(g.status));
|
|
3958
|
-
|
|
4153
|
+
// Offline connection line: connOffline is the grace-debounced (~800ms) SSE-down
|
|
4154
|
+
// flag from the reconnect loop. When offline we always show the tray (even with
|
|
4155
|
+
// no queued items) so the red status line + Reconnect is visible above the composer.
|
|
4156
|
+
const offline = connOffline;
|
|
4157
|
+
if (!ob.length && !stacked.length && !planning.length && !offline) { q.classList.remove('show'); q.innerHTML = ''; return; }
|
|
3959
4158
|
q.classList.add('show');
|
|
4159
|
+
// mirror connectionBannerState({online}) from engine/lib.mjs (a browser static file
|
|
4160
|
+
// can't import the module) — offline ⇒ red inline line + Reconnect atop the tray.
|
|
4161
|
+
const connHtml = offline
|
|
4162
|
+
? '<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>'
|
|
4163
|
+
: '';
|
|
3960
4164
|
// Optimistic outbox items (saved to localStorage BEFORE the network call) render
|
|
3961
4165
|
// first — instant, offline-safe, never lost. 'unsent' rows are tap-to-resend.
|
|
3962
4166
|
const obRows = ob.map((o) => {
|
|
3963
4167
|
const unsent = o.status === 'unsent';
|
|
3964
4168
|
const lead = unsent ? '<span class="qdot"></span>' : '<span class="spinner" style="width:9px;height:9px;flex:0 0 9px"></span>';
|
|
3965
|
-
|
|
4169
|
+
// mirror unsentMetaLabel({online}) from engine/lib.mjs — offline resend is a lie
|
|
4170
|
+
// (recovery is the connline's Reconnect), so drop tap-to-resend and swap the label.
|
|
4171
|
+
const resendable = unsent && !offline;
|
|
4172
|
+
const metaTxt = unsent ? (offline ? 'Waiting for connection' : 'Unsent — tap to resend') : 'Sending…';
|
|
4173
|
+
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>`;
|
|
3966
4174
|
}).join('');
|
|
3967
4175
|
const planningRows = planning.map((g) =>
|
|
3968
4176
|
`<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('');
|
|
3969
4177
|
// Flagship: qtray label is the quiet mono "Queued · N" (flagship que1); other
|
|
3970
4178
|
// layouts keep the explanatory sentence.
|
|
3971
4179
|
const qn = ob.length + stacked.length + planning.length;
|
|
3972
|
-
|
|
4180
|
+
const headHtml = qn > 0 ? `<div class="qhead">${state.layout === 'flagship' ? `Queued · ${qn}` : `${qn} queued · goals become To dos in ~30s`}</div>` : '';
|
|
4181
|
+
q.innerHTML = connHtml + headHtml + obRows + planningRows + stacked.map((g) => {
|
|
3973
4182
|
if (state.editing === g.id) {
|
|
3974
4183
|
return `<textarea class="qedit" id="qedit-${g.id}">${esc(g.text)}</textarea>
|
|
3975
4184
|
<div class="qbtns">
|
|
@@ -3987,6 +4196,7 @@ function renderQueue(){
|
|
|
3987
4196
|
<button class="qico del qd" data-id="${g.id}" title="Delete">${ICONS.trash}</button></div>`;
|
|
3988
4197
|
}).join('');
|
|
3989
4198
|
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); };
|
|
4199
|
+
for (const el of q.querySelectorAll('[data-reconnect]')) el.onclick = () => forceReconnect();
|
|
3990
4200
|
const qrows = [...q.querySelectorAll('.qrow[data-goal]')];
|
|
3991
4201
|
for (const el of qrows) {
|
|
3992
4202
|
el.addEventListener('dragstart', (e) => { state.dragGoal = Number(el.dataset.goal); el.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; });
|
|
@@ -4313,14 +4523,22 @@ function renderTasks(){
|
|
|
4313
4523
|
for (const b of $('tasklist').querySelectorAll('[data-qapprove]')) {
|
|
4314
4524
|
b.onclick = async (e) => { e.stopPropagation();
|
|
4315
4525
|
const bx = b.getBoundingClientRect(); // capture before await/re-render — burst FROM the ✓ button
|
|
4316
|
-
let
|
|
4317
|
-
|
|
4318
|
-
|
|
4526
|
+
let ok = true, mergeGoal = null;
|
|
4527
|
+
for (const id of b.dataset.qapprove.split(',')) {
|
|
4528
|
+
const r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
|
|
4529
|
+
if (!r.ok){ ok = false; break; }
|
|
4530
|
+
const j = await r.json(); if (j && j.needsMerge) mergeGoal = j.goal; // 07-20c: PR goal
|
|
4531
|
+
}
|
|
4532
|
+
if (!ok){ showErr('Approve failed.'); return; }
|
|
4533
|
+
await refresh();
|
|
4534
|
+
if (mergeGoal){ confirmMerge(mergeGoal); return; } // merge instead of confetti
|
|
4535
|
+
confettiBurst(bx.left + bx.width / 2, bx.top + bx.height / 2); };
|
|
4319
4536
|
}
|
|
4320
4537
|
for (const b of $('tasklist').querySelectorAll('[data-qdismiss]')) {
|
|
4538
|
+
// 07-20c: the inline ✕ is a pure Reject — park the goal in the Rejected column (/reject),
|
|
4539
|
+
// no auto-rework. Rework is asked for via chat.
|
|
4321
4540
|
b.onclick = async (e) => { e.stopPropagation();
|
|
4322
|
-
let r; for (const id of b.dataset.qdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/
|
|
4323
|
-
headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: '' }) });
|
|
4541
|
+
let r; for (const id of b.dataset.qdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/reject`), { method: 'POST' });
|
|
4324
4542
|
qrefresh(r, 'Dismiss failed.'); };
|
|
4325
4543
|
}
|
|
4326
4544
|
// Archive: retire an unfinished goal cleanly (no rework spawned). The escape
|
|
@@ -5069,7 +5287,39 @@ async function setRequirementStatus(goalId, taskId, status){
|
|
|
5069
5287
|
// backend has never heard of, e.g. an injected verify fixture) and then
|
|
5070
5288
|
// persisted server-side; a real goal's SSE 'goal' event reconciles it right
|
|
5071
5289
|
// after, a no-op since the value already matches.
|
|
5290
|
+
// 07-20c: a review goal that HAS a PR is finished by MERGING the PR, not by approving in
|
|
5291
|
+
// the app (that was the "approved but PR still open" zombie). Reuses the confirmDelete modal
|
|
5292
|
+
// as a merge-confirm: primary action opens the PR; the goal stays in review and
|
|
5293
|
+
// syncGoalMerges flips it to done once GitHub reports the merge.
|
|
5294
|
+
async function confirmMerge(goal){
|
|
5295
|
+
const prNo = goal && goal.pr ? (String(goal.pr).match(/pull\/(\d+)/) ?? [, ''])[1] : '';
|
|
5296
|
+
const open = await confirmDelete({
|
|
5297
|
+
title: 'Merge the Pull Request to finish',
|
|
5298
|
+
body: 'This goal has a PR. Approving here does not merge it — open the PR and merge it, and it moves to Done automatically.',
|
|
5299
|
+
previewHtml: `<div class="pvtitle">${esc(goal?.text || '')}</div>${prNo ? `<div class="pvpr">PR #${prNo} ↗</div>` : ''}`,
|
|
5300
|
+
previewPlain: true,
|
|
5301
|
+
confirmLabel: 'Open PR', cancelLabel: 'Close', confirmDanger: false,
|
|
5302
|
+
});
|
|
5303
|
+
if (open && goal && goal.pr) window.open(goal.pr, '_blank', 'noopener');
|
|
5304
|
+
}
|
|
5305
|
+
// Shared approve-response handler for the simple approve surfaces (inline ✓, review-card ✓,
|
|
5306
|
+
// See-all). Returns true if the goal actually advanced (done), false if it needs a merge.
|
|
5307
|
+
function handleApproveJson(json, goal){
|
|
5308
|
+
if (json && json.needsMerge){ if (json.goal) upsert(state.goals, json.goal); confirmMerge(json.goal || goal); return false; }
|
|
5309
|
+
if (json) upsert(state.goals, json);
|
|
5310
|
+
return true;
|
|
5311
|
+
}
|
|
5072
5312
|
async function advanceGoalAfterAllApproved(goal){
|
|
5313
|
+
// 07-20c: a PR goal is not finished here — route it to the merge-confirm dialog and keep
|
|
5314
|
+
// it in review (no optimistic done, no celebration until the PR is actually merged).
|
|
5315
|
+
if (goal.pr){
|
|
5316
|
+
try {
|
|
5317
|
+
const r = await fetch(withKey(`/api/goals/${goal.id}/advance-review`), { method: 'POST' });
|
|
5318
|
+
if (r.ok){ const j = await r.json(); if (j.goal) upsert(state.goals, j.goal); if (j.needsMerge) confirmMerge(j.goal || goal); }
|
|
5319
|
+
} catch { /* offline — leave as review */ }
|
|
5320
|
+
renderReviewChecklist(); render();
|
|
5321
|
+
return;
|
|
5322
|
+
}
|
|
5073
5323
|
const fly = captureReviewFly(goal.id);
|
|
5074
5324
|
goal.status = 'done';
|
|
5075
5325
|
renderReviewChecklist(); render();
|
|
@@ -5139,8 +5389,16 @@ $('reviewApprove').onclick = async () => {
|
|
|
5139
5389
|
try {
|
|
5140
5390
|
const r = await fetch(withKey(`/api/goals/${goal.id}/approve`), { method: 'POST' });
|
|
5141
5391
|
if (!r.ok) throw new Error();
|
|
5392
|
+
const j = await r.json();
|
|
5393
|
+
if (j.needsMerge){ // 07-20c: PR goal — send to the merge dialog, no celebration/advance
|
|
5394
|
+
if (j.goal) upsert(state.goals, j.goal);
|
|
5395
|
+
renderReviewChecklist();
|
|
5396
|
+
$('reviewApprove').disabled = false;
|
|
5397
|
+
confirmMerge(j.goal || goal);
|
|
5398
|
+
return;
|
|
5399
|
+
}
|
|
5142
5400
|
const fly = captureReviewFly(goal.id);
|
|
5143
|
-
upsert(state.goals,
|
|
5401
|
+
upsert(state.goals, j);
|
|
5144
5402
|
renderReviewChecklist();
|
|
5145
5403
|
const dm = $('reviewDoneMsg');
|
|
5146
5404
|
if (dm){ dm.classList.remove('pop'); void dm.offsetWidth; dm.classList.add('pop'); }
|
|
@@ -5189,7 +5447,21 @@ async function sendReviewDismiss(text){
|
|
|
5189
5447
|
render();
|
|
5190
5448
|
} catch { showErr('Dismiss failed.'); }
|
|
5191
5449
|
}
|
|
5192
|
-
|
|
5450
|
+
// 07-20c: the review dialog's Reject button parks the goal in the Rejected column (/reject),
|
|
5451
|
+
// no auto-rework. Typing feedback into the box below and pressing Send still reworks (/dismiss).
|
|
5452
|
+
async function sendReviewReject(){
|
|
5453
|
+
const goal = state.goals.find((g) => g.id === state.reviewOpenGoal);
|
|
5454
|
+
if (!goal) return;
|
|
5455
|
+
try {
|
|
5456
|
+
const r = await fetch(withKey(`/api/goals/${goal.id}/reject`), { method: 'POST' });
|
|
5457
|
+
if (!r.ok) throw new Error();
|
|
5458
|
+
const body = await r.json();
|
|
5459
|
+
upsert(state.goals, body.goal);
|
|
5460
|
+
closeReviewChecklist();
|
|
5461
|
+
render();
|
|
5462
|
+
} catch { showErr('Dismiss failed.'); }
|
|
5463
|
+
}
|
|
5464
|
+
$('reviewDismiss').onclick = () => sendReviewReject();
|
|
5193
5465
|
$('reviewFixSend').onclick = () => { const t = $('reviewFixInput').value.trim(); if (t || reviewAttach.length) sendReviewDismiss(t); };
|
|
5194
5466
|
$('reviewFixInput').addEventListener('keydown', (e) => {
|
|
5195
5467
|
if (e.key === 'Enter') { const t = $('reviewFixInput').value.trim(); if (t || reviewAttach.length) sendReviewDismiss(t); }
|
|
@@ -5370,12 +5642,26 @@ $('gdApprove').onclick = async () => {
|
|
|
5370
5642
|
try {
|
|
5371
5643
|
const r = await fetch(withKey(`/api/goals/${goalId}/approve`), { method: 'POST' });
|
|
5372
5644
|
if (!r.ok) throw new Error();
|
|
5373
|
-
|
|
5645
|
+
handleApproveJson(await r.json(), state.goals.find((g) => g.id === goalId)); // 07-20c: PR goal → merge dialog
|
|
5374
5646
|
closeGoalDetail();
|
|
5375
5647
|
render();
|
|
5376
5648
|
} catch { showErr('Approve failed.'); }
|
|
5377
5649
|
$('gdApprove').disabled = false;
|
|
5378
5650
|
};
|
|
5651
|
+
// 07-20c: the goal-detail Reject button parks the goal in the Rejected column (/reject).
|
|
5652
|
+
// Revision text typed into gdSend still goes to /dismiss (rework) via sendDismiss below.
|
|
5653
|
+
async function sendReject(goalId){
|
|
5654
|
+
$('gdApprove').disabled = true; $('gdDismiss').disabled = true; $('gdSend').disabled = true;
|
|
5655
|
+
try {
|
|
5656
|
+
const r = await fetch(withKey(`/api/goals/${goalId}/reject`), { method: 'POST' });
|
|
5657
|
+
if (!r.ok) throw new Error();
|
|
5658
|
+
const body = await r.json();
|
|
5659
|
+
upsert(state.goals, body.goal);
|
|
5660
|
+
closeGoalDetail();
|
|
5661
|
+
render();
|
|
5662
|
+
} catch { showErr('Dismiss failed.'); }
|
|
5663
|
+
$('gdApprove').disabled = false; $('gdDismiss').disabled = false; $('gdSend').disabled = false;
|
|
5664
|
+
}
|
|
5379
5665
|
async function sendDismiss(goalId, text){
|
|
5380
5666
|
$('gdApprove').disabled = true; $('gdDismiss').disabled = true; $('gdSend').disabled = true;
|
|
5381
5667
|
try {
|
|
@@ -5416,7 +5702,7 @@ async function discardGoal(goalId){
|
|
|
5416
5702
|
} catch { showErr('Discard failed.'); }
|
|
5417
5703
|
$('gdSend').disabled = false; $('gdDiscard').disabled = false;
|
|
5418
5704
|
}
|
|
5419
|
-
$('gdDismiss').onclick = () => { const goalId = state.goalDetailOpen; if (goalId != null)
|
|
5705
|
+
$('gdDismiss').onclick = () => { const goalId = state.goalDetailOpen; if (goalId != null) sendReject(goalId); };
|
|
5420
5706
|
$('gdDiscard').onclick = () => { const goalId = state.goalDetailOpen; if (goalId != null) discardGoal(goalId); };
|
|
5421
5707
|
$('gdSend').onclick = () => {
|
|
5422
5708
|
const goalId = state.goalDetailOpen;
|
|
@@ -5460,7 +5746,7 @@ const fsUI = {
|
|
|
5460
5746
|
rv: [1, 2, 3].includes(Number(localStorage.getItem('fsRv'))) ? Number(localStorage.getItem('fsRv')) : 3, // review view: 3 thumbnails (default) / 1 list / 2 compact
|
|
5461
5747
|
band: Number(localStorage.getItem('fsBand') ?? 0), // theme-6 band choice
|
|
5462
5748
|
revMore: false, // "+N more" fold on the review list (§1.5-8)
|
|
5463
|
-
todoFold: false, doneOpen: false, todoMore: false,
|
|
5749
|
+
todoFold: false, doneOpen: false, rejOpen: false, todoMore: false,
|
|
5464
5750
|
todoView: localStorage.getItem('fsTodoView') === 'board' ? 'board' : 'list', // To Do: list (default) / kanban board (Masa 2026-07-16)
|
|
5465
5751
|
logOpen: new Set(), logFocus: null, // "View log" targets
|
|
5466
5752
|
repsOpen: new Set(), // reply-group folds
|
|
@@ -5469,6 +5755,7 @@ const FS_ICONS = {
|
|
|
5469
5755
|
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>',
|
|
5470
5756
|
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>',
|
|
5471
5757
|
del: '<svg viewBox="0 0 24 24"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg>',
|
|
5758
|
+
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 */
|
|
5472
5759
|
};
|
|
5473
5760
|
// per-project count icons (HANDOFF-v45 §10): To Do = rounded square, Review = Scan
|
|
5474
5761
|
// (magnifier) — both stroked, NEUTRAL ink (never green); shape is the only distinction.
|
|
@@ -5984,14 +6271,19 @@ function renderFsReview(){
|
|
|
5984
6271
|
const cnote = carried.length ? `<span class="fnote">${foldLangFor(g0) === 'ja'
|
|
5985
6272
|
? `+ ${carried.map((x) => `#${x.id}`).join(', ')} の仕事もここに入っています`
|
|
5986
6273
|
: `also carries ${carried.map((x) => `#${x.id}`).join(', ')}`}</span>` : '';
|
|
5987
|
-
|
|
6274
|
+
// Title = the goal's own title (saReviewTitle → text-first), same as the See-all
|
|
6275
|
+
// Ledger and the To Do list, so a review card and its list row read identically
|
|
6276
|
+
// (Masa 2026-07-20: this card showed reviewSummary.check "…が出ない" while the list
|
|
6277
|
+
// showed the request text). Plan-reviews keep their "PLAN — approve to execute:"
|
|
6278
|
+
// checkLine, which is the actionable label there, not a title mismatch.
|
|
6279
|
+
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>
|
|
5988
6280
|
<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>`;
|
|
5989
6281
|
};
|
|
5990
6282
|
// A folded goal is settled, not failed: no ✓/✕ (the thing to approve lives on the goal
|
|
5991
6283
|
// it was folded into), no red, no painted surface (§1.5-8) — just its own number, what
|
|
5992
6284
|
// it asked for, and where the work went. Tapping it opens that goal (§1.5-9 wording).
|
|
5993
6285
|
const foldCard = (g) => {
|
|
5994
|
-
const line =
|
|
6286
|
+
const line = saReviewTitle(g).slice(0, 60); // title-first, same as the review card above
|
|
5995
6287
|
const mergedNote = foldLangFor(g) === 'ja' ? `→ #${esc(String(g.foldedInto))} にまとめました` : `merged into #${esc(String(g.foldedInto))}`;
|
|
5996
6288
|
return `<div class="rcard noimg fold" data-fsfold="${g.id}">
|
|
5997
6289
|
<div class="rc"><div class="t"><span class="rid gno" data-n="${g.id}">#${g.id}</span>${esc(line)}</div>
|
|
@@ -6046,11 +6338,18 @@ function renderFsReview(){
|
|
|
6046
6338
|
else openGoalDetail(target.id);
|
|
6047
6339
|
};
|
|
6048
6340
|
for (const b of el.querySelectorAll('[data-fsapprove]')) b.onclick = async (e) => { e.stopPropagation();
|
|
6049
|
-
let
|
|
6050
|
-
|
|
6341
|
+
let ok = true, mergeGoal = null;
|
|
6342
|
+
for (const id of b.dataset.fsapprove.split(',')) {
|
|
6343
|
+
const r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
|
|
6344
|
+
if (!r.ok){ ok = false; break; }
|
|
6345
|
+
const j = await r.json(); if (j && j.needsMerge) mergeGoal = j.goal; // 07-20c: PR goal
|
|
6346
|
+
}
|
|
6347
|
+
if (!ok){ showErr('Approve failed.'); return; }
|
|
6348
|
+
await refresh();
|
|
6349
|
+
if (mergeGoal) confirmMerge(mergeGoal); else celebrateApprove(); };
|
|
6051
6350
|
for (const b of el.querySelectorAll('[data-fsdismiss]')) b.onclick = async (e) => { e.stopPropagation();
|
|
6052
|
-
|
|
6053
|
-
|
|
6351
|
+
// 07-20c: the review-card ✕ is a pure Reject → Rejected column (/reject), no auto-rework.
|
|
6352
|
+
let r; for (const id of b.dataset.fsdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/reject`), { method: 'POST' });
|
|
6054
6353
|
if (r && r.ok) await refresh(); else showErr('Dismiss failed.'); };
|
|
6055
6354
|
}
|
|
6056
6355
|
// Shared To Do bucketing (list rows AND the kanban board read the same groups —
|
|
@@ -6070,13 +6369,14 @@ function fsBucketTodo(){
|
|
|
6070
6369
|
queued: list.filter((t) => t.status === 'queued' && !t.reply).sort(byQueue),
|
|
6071
6370
|
doneAll: list.filter((t) => ['done', 'skipped'].includes(t.status)).sort((a, b) => b.num - a.num),
|
|
6072
6371
|
later: pgoals.filter((g) => g.status === 'pending'),
|
|
6372
|
+
rejected: pgoals.filter((g) => g.status === 'rejected'), // 07-20c: Reject parks goals here (own fold, like Done)
|
|
6073
6373
|
};
|
|
6074
6374
|
}
|
|
6075
6375
|
// To Do (below Review): Doing rows with bar + mono %, Next up in queue order,
|
|
6076
6376
|
// error as a small in-row colour element (§1.5-8), quiet Done fold.
|
|
6077
6377
|
function renderFsTodo(){
|
|
6078
6378
|
const el = $('fsTodoSec'); if (!el) return;
|
|
6079
|
-
const { pgoals, list, running, failed, blocked, needsInput, queued, doneAll, later } = fsBucketTodo();
|
|
6379
|
+
const { pgoals, list, running, failed, blocked, needsInput, queued, doneAll, later, rejected } = fsBucketTodo();
|
|
6080
6380
|
const paused = !!state.projects.find((p) => p.id === state.active)?.paused; // list版 Doing トグルの状態
|
|
6081
6381
|
// 一時停止したゴール(blocked kind:'paused')は Doing に「Paused」1行で留める(Masa 2026-07-19)。
|
|
6082
6382
|
// Needs you 側(failed/blocked)へは出さず、その走行/中断タスク行も抑止して二重行を作らない。
|
|
@@ -6177,7 +6477,8 @@ function renderFsTodo(){
|
|
|
6177
6477
|
<div class="tl">
|
|
6178
6478
|
${(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}` : ''}
|
|
6179
6479
|
${needsRows ? `<span class="tlcap needs"${(doingRows || paused) ? '' : ' style="padding-top:0"'}>Needs you</span>${needsRows}` : ''}
|
|
6180
|
-
${
|
|
6480
|
+
${rejected.length ? `<span class="tlcap rejcap"${(doingRows || paused) || needsRows ? '' : ' style="padding-top:0"'}>Dismissed<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('')}` : ''}
|
|
6481
|
+
${upRows ? `<span class="tlcap"${(doingRows || paused) || needsRows || rejected.length ? '' : ' style="padding-top:0"'}>Next up</span>${upRows}` : ''}
|
|
6181
6482
|
${queued.length > NCAP ? `<div class="morefold" id="fsTodoMore">${fsUI.todoMore ? 'less' : `+${queued.length - NCAP} more`}<span class="g">›</span></div>` : ''}
|
|
6182
6483
|
${laterRows ? `<span class="tlcap">Later</span>${laterRows}` : ''}
|
|
6183
6484
|
<button class="qadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button>
|
|
@@ -6190,6 +6491,16 @@ function renderFsTodo(){
|
|
|
6190
6491
|
if (qadd) qadd.onclick = () => $('input')?.focus();
|
|
6191
6492
|
const dl = el.querySelector('#fsDoneLine');
|
|
6192
6493
|
if (dl) dl.onclick = () => { fsUI.doneOpen = !fsUI.doneOpen; dl.classList.toggle('open', fsUI.doneOpen); el.querySelector('#fsDonelist').hidden = !fsUI.doneOpen; };
|
|
6494
|
+
for (const b of el.querySelectorAll('[data-fsreopen]')) b.onclick = async (e) => { e.stopPropagation();
|
|
6495
|
+
const r = await fetch(withKey(`/api/goals/${b.dataset.fsreopen}/reopen`), { method: 'POST' });
|
|
6496
|
+
if (!r.ok) showErr('Reopen failed.'); else await refresh(); };
|
|
6497
|
+
for (const b of el.querySelectorAll('[data-fsrejdel]')) b.onclick = async (e) => { e.stopPropagation();
|
|
6498
|
+
const preview = b.closest('.trow')?.querySelector('.tt')?.textContent || '';
|
|
6499
|
+
// Delete on a rejected goal = revert: it closes the PR + drops the worktree, so a rejected
|
|
6500
|
+
// goal that had a PR doesn't leave an orphan PR behind (DELETE would 409 — rejected isn't deletable).
|
|
6501
|
+
if (!(await confirmDelete({ title: 'Discard this rejected goal?', preview }))) return;
|
|
6502
|
+
const r = await fetch(withKey(`/api/goals/${b.dataset.fsrejdel}/revert`), { method: 'POST' });
|
|
6503
|
+
if (!r.ok) showErr('Delete failed.'); else await refresh(); };
|
|
6193
6504
|
const more = el.querySelector('#fsTodoMore');
|
|
6194
6505
|
if (more) more.onclick = () => { fsUI.todoMore = !fsUI.todoMore; renderFsTodo(); };
|
|
6195
6506
|
for (const b of el.querySelectorAll('[data-fsreps]')) b.onclick = () => {
|
|
@@ -6512,7 +6823,20 @@ function renderClawdHero(){
|
|
|
6512
6823
|
$('fsRailTg').innerHTML = tgSvg('left', !root.classList.contains('railfold'));
|
|
6513
6824
|
$('fsLaneTg').innerHTML = tgSvg('right', !root.classList.contains('lanefold'));
|
|
6514
6825
|
};
|
|
6515
|
-
$('fsRailTg').onclick = () => {
|
|
6826
|
+
$('fsRailTg').onclick = () => { // §1.5-7: fold
|
|
6827
|
+
root.classList.toggle('railfold'); tgUpdate();
|
|
6828
|
+
// Re-align the gear only AFTER the rail's fold animation settles. The old
|
|
6829
|
+
// synchronous fsGearAlignX() measured a half-collapsed rail mid-transition and
|
|
6830
|
+
// left the gear at a stale offset once the width finished moving — "たたむと歯車が
|
|
6831
|
+
// ズレる" (#474/#548, Masa 2026-07-19). Clear any stale offset now, re-measure on
|
|
6832
|
+
// transitionend (flex-basis), with a timeout fallback for reduced-motion / .noanim.
|
|
6833
|
+
const btn = $('fsMoreBtn'); if (btn) btn.style.transform = '';
|
|
6834
|
+
const rail = root.querySelector('.fsrail');
|
|
6835
|
+
let aligned = false;
|
|
6836
|
+
const align = () => { if (aligned) return; aligned = true; fsGearAlignX(); };
|
|
6837
|
+
if (rail) rail.addEventListener('transitionend', function te(e){ if (e.propertyName !== 'flex-basis') return; rail.removeEventListener('transitionend', te); align(); });
|
|
6838
|
+
setTimeout(align, 340);
|
|
6839
|
+
};
|
|
6516
6840
|
$('fsLaneTg').onclick = () => { root.classList.toggle('lanefold'); tgUpdate(); };
|
|
6517
6841
|
// List/Board toggle — wired once (static, unlike the row it used to live inside which
|
|
6518
6842
|
// rebuilds on every renderFsTodo()); syncFsTvSeg() paints .on and is called from renderFsLane.
|
|
@@ -6756,12 +7080,22 @@ function clearBillingBlocked(){
|
|
|
6756
7080
|
}
|
|
6757
7081
|
function showErr(msg){
|
|
6758
7082
|
const bar = $('errbar');
|
|
6759
|
-
bar.classList.remove('billing'); // shed the paywall styling if it was last used for that
|
|
7083
|
+
bar.classList.remove('billing', 'ok'); // shed the paywall/success styling if it was last used for that
|
|
6760
7084
|
billingBlockedActive = false;
|
|
6761
7085
|
bar.textContent = msg; bar.style.display = 'block';
|
|
6762
7086
|
clearTimeout(errTimer);
|
|
6763
7087
|
errTimer = setTimeout(() => { if (authDown) return; bar.style.display = 'none'; }, 6000);
|
|
6764
7088
|
}
|
|
7089
|
+
// 07-20c: a neutral/success toast (green), reusing the #errbar surface. Used when a PR merge
|
|
7090
|
+
// lands back in the app (review→done via syncGoalMerges).
|
|
7091
|
+
function showToast(msg){
|
|
7092
|
+
const bar = $('errbar');
|
|
7093
|
+
bar.classList.remove('billing'); bar.classList.add('ok');
|
|
7094
|
+
billingBlockedActive = false;
|
|
7095
|
+
bar.textContent = msg; bar.style.display = 'block';
|
|
7096
|
+
clearTimeout(errTimer);
|
|
7097
|
+
errTimer = setTimeout(() => { bar.classList.remove('ok'); bar.style.display = 'none'; }, 4000);
|
|
7098
|
+
}
|
|
6765
7099
|
|
|
6766
7100
|
// Free-limit copy (H22, Masa 2026-07-17 確定 · ja+en). The sentence is content,
|
|
6767
7101
|
// so it follows the user's language; the mono label and buttons stay English
|
|
@@ -7109,15 +7443,55 @@ function scheduleActRender(){ if (_actT) { _actP = true; return; } renderActPane
|
|
|
7109
7443
|
// (connection refused = fatal, no auto-retry), so we own the reconnect loop.
|
|
7110
7444
|
let esRetryMs = 1000;
|
|
7111
7445
|
let sseUp = false; // live engine link — the honest signal behind the MCP tab's status dot (v46)
|
|
7446
|
+
// Connection status line (queue tray): grace-debounce the offline state ~800ms so
|
|
7447
|
+
// sub-second SSE blips don't flash a red line. connOffline is the debounced flag
|
|
7448
|
+
// renderQueue() reads; connSetOnline/Offline flip it around real sseUp changes.
|
|
7449
|
+
let connOffline = false, _connGraceT = null, _sseReconnectT = null, _es = null, _healthT = null;
|
|
7450
|
+
function connSetOnline(){ if (_connGraceT) { clearTimeout(_connGraceT); _connGraceT = null; } if (connOffline) { connOffline = false; renderQueue(); } }
|
|
7451
|
+
function connSetOffline(){ if (_connGraceT || connOffline) return; _connGraceT = setTimeout(() => { _connGraceT = null; connOffline = true; renderQueue(); }, 800); }
|
|
7452
|
+
// Reconnect button: drop any pending backoff timer and reopen the EventSource now.
|
|
7453
|
+
function forceReconnect(){ if (_sseReconnectT) { clearTimeout(_sseReconnectT); _sseReconnectT = null; } try { _es && _es.close(); } catch { /* already closed */ } esRetryMs = 1000; connectSSE(); }
|
|
7454
|
+
// Robust liveness backstop that does NOT depend on EventSource onerror firing.
|
|
7455
|
+
// The browser throttles background tabs, so onerror can be delayed or never fire and
|
|
7456
|
+
// sseUp goes stale-true — that is why a dropped link showed no red line until the tab
|
|
7457
|
+
// was refreshed. A cheap HEAD probe (visible tabs only; also fired on tab-focus) flips
|
|
7458
|
+
// the connline within seconds of the user viewing a dead link, and forces a fresh SSE
|
|
7459
|
+
// so recovery (onopen -> clear + flush) still happens when onerror never fired.
|
|
7460
|
+
async function checkHealth(){
|
|
7461
|
+
if (document.hidden) return;
|
|
7462
|
+
let ok = false;
|
|
7463
|
+
try {
|
|
7464
|
+
const c = new AbortController(); const to = setTimeout(() => c.abort(), 3000);
|
|
7465
|
+
const r = await fetch(withKey('/'), { method: 'HEAD', cache: 'no-store', signal: c.signal });
|
|
7466
|
+
clearTimeout(to); ok = r.ok;
|
|
7467
|
+
} catch { ok = false; }
|
|
7468
|
+
if (!ok) {
|
|
7469
|
+
connSetOffline(); // grace-debounced ~800ms
|
|
7470
|
+
if (!_sseReconnectT) { try { _es && _es.close(); } catch {} sseUp = false; esRetryMs = Math.min(esRetryMs, 4000); _sseReconnectT = setTimeout(connectSSE, esRetryMs); }
|
|
7471
|
+
} else if (connOffline && !_sseReconnectT) {
|
|
7472
|
+
forceReconnect(); // server reachable again but the line is still up → reconnect to clear + flush
|
|
7473
|
+
}
|
|
7474
|
+
}
|
|
7475
|
+
function startHealthPoll(){ clearInterval(_healthT); _healthT = setInterval(checkHealth, 4000); }
|
|
7112
7476
|
function connectSSE(){
|
|
7113
|
-
|
|
7114
|
-
es
|
|
7477
|
+
_sseReconnectT = null; // this attempt supersedes any scheduled retry
|
|
7478
|
+
const es = _es = new EventSource(withKey('/api/events'));
|
|
7479
|
+
es.onopen = () => { esRetryMs = 1000; sseUp = true; connSetOnline(); console.log('[manager] sse connected'); refresh(); flushOutbox();
|
|
7115
7480
|
if ($('gearPop').classList.contains('open') && custTab === 'mcp') buildGearPop(); };
|
|
7116
7481
|
es.onmessage = (e) => {
|
|
7117
7482
|
const m = JSON.parse(e.data);
|
|
7118
7483
|
if (m.ev === 'goal') {
|
|
7119
|
-
const
|
|
7484
|
+
const prev = state.goals.find((g) => g.id === m.goal.id);
|
|
7485
|
+
const line = goalLogLine(m.goal, prev);
|
|
7120
7486
|
if (line) state.logEntries = pushCapped(state.logEntries, { text: line, ts: Date.now() });
|
|
7487
|
+
// 07-20c: a PR goal reaches 'done' ONLY via a real merge (syncGoalMerges) — so a
|
|
7488
|
+
// review→done flip on a goal that has a PR means the user just merged it on GitHub and
|
|
7489
|
+
// came back. Celebrate (confetti) + a "merged" toast so the merge is unmistakable.
|
|
7490
|
+
if (prev && prev.status === 'review' && m.goal.status === 'done' && m.goal.pr) {
|
|
7491
|
+
try { celebrateApprove(); } catch {}
|
|
7492
|
+
const prNo = (String(m.goal.pr).match(/pull\/(\d+)/) ?? [, ''])[1];
|
|
7493
|
+
showToast(`PR #${prNo} merged — “${(m.goal.text || '').replace(/\s+/g, ' ').slice(0, 48)}” is Done`);
|
|
7494
|
+
}
|
|
7121
7495
|
upsert(state.goals, m.goal);
|
|
7122
7496
|
}
|
|
7123
7497
|
else if (m.ev === 'goal-deleted') state.goals = state.goals.filter((g) => g.id !== m.id);
|
|
@@ -7145,13 +7519,19 @@ function connectSSE(){
|
|
|
7145
7519
|
scheduleRender();
|
|
7146
7520
|
};
|
|
7147
7521
|
es.onerror = () => {
|
|
7148
|
-
|
|
7149
|
-
|
|
7150
|
-
|
|
7151
|
-
|
|
7152
|
-
|
|
7153
|
-
|
|
7154
|
-
|
|
7522
|
+
// Native EventSource sits silently in CONNECTING and auto-retries on a dropped
|
|
7523
|
+
// or refused connection — readyState only reaches CLOSED on a non-2xx response.
|
|
7524
|
+
// Gating on CLOSED therefore missed every REAL relay/network drop (verified
|
|
7525
|
+
// 2026-07-20: CDP-offline AND killing the server both left the old gate un-fired,
|
|
7526
|
+
// so the red line never showed). Treat ANY error as the live link going down:
|
|
7527
|
+
// stop the native retry (close), flag offline (grace-debounced), own the backoff.
|
|
7528
|
+
if (_sseReconnectT) return; // a reconnect is already scheduled — don't stack
|
|
7529
|
+
try { es.close(); } catch { /* already closed */ }
|
|
7530
|
+
if (sseUp) { sseUp = false; if ($('gearPop').classList.contains('open') && custTab === 'mcp') buildGearPop(); }
|
|
7531
|
+
connSetOffline(); // arm the ~800ms grace timer before the red connline shows
|
|
7532
|
+
console.log(`[manager] sse down (readyState=${es.readyState}) — reconnecting in ${esRetryMs}ms`);
|
|
7533
|
+
_sseReconnectT = setTimeout(connectSSE, esRetryMs);
|
|
7534
|
+
esRetryMs = Math.min(esRetryMs * 2, 15000);
|
|
7155
7535
|
};
|
|
7156
7536
|
}
|
|
7157
7537
|
|
|
@@ -7302,7 +7682,8 @@ async function boot(){
|
|
|
7302
7682
|
await refresh();
|
|
7303
7683
|
flushOutbox();
|
|
7304
7684
|
connectSSE();
|
|
7305
|
-
|
|
7685
|
+
startHealthPoll();
|
|
7686
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden) { refresh(); flushOutbox(); checkHealth(); } });
|
|
7306
7687
|
// belt and suspenders for the never-lose guarantee: even if every event
|
|
7307
7688
|
// path misfires, unsent memos are retried on a slow heartbeat
|
|
7308
7689
|
setInterval(() => { if (outbox.load().some((o) => o.status === 'unsent')) flushOutbox(); }, 15000);
|
|
@@ -7713,16 +8094,46 @@ function saRows(){
|
|
|
7713
8094
|
goalText: String(g?.text ?? '').replace(/\s+/g, ' ').trim(),
|
|
7714
8095
|
did, siblings, risk: g?.risk ?? null,
|
|
7715
8096
|
note: g?.retest?.passed ? g.retest.note : null,
|
|
7716
|
-
pr: r.pr,
|
|
8097
|
+
pr: r.pr,
|
|
8098
|
+
// Explicit review-summary display fields (spec §11: the card's shape follows the
|
|
8099
|
+
// task). A goal's reviewSummary may carry them directly (e.g. a conversational task
|
|
8100
|
+
// that reports an Answer + its Activity, or a ticket whose test count / diff totals
|
|
8101
|
+
// are recorded rather than re-derived); each falls back to the computed value.
|
|
8102
|
+
testResult: (typeof (g?.reviewSummary?.tests) === 'string')
|
|
8103
|
+
? { ran: true, ok: true, passed: parseInt(g.reviewSummary.tests, 10) || 0, failed: 0, label: g.reviewSummary.tests }
|
|
8104
|
+
: r.testResult,
|
|
8105
|
+
answer: g?.reviewSummary?.answer ?? null,
|
|
8106
|
+
activity: Array.isArray(g?.reviewSummary?.activity) ? g.reviewSummary.activity : null,
|
|
8107
|
+
discussion: Array.isArray(g?.reviewSummary?.discussion) ? g.reviewSummary.discussion : null,
|
|
8108
|
+
changedDesc: g?.reviewSummary?.changed ?? null,
|
|
8109
|
+
metaFiles: Array.isArray(g?.reviewSummary?.files) ? g.reviewSummary.files : null,
|
|
8110
|
+
metaAdd: g?.reviewSummary?.a ?? null, metaDel: g?.reviewSummary?.d ?? null,
|
|
8111
|
+
ghRows: Array.isArray(g?.reviewSummary?.ghdiff) ? g.reviewSummary.ghdiff : null,
|
|
8112
|
+
diffTotal: g?.reviewSummary?.diffTotal ?? null,
|
|
7717
8113
|
proofTaskId: pt?.id ?? null, proofImg: pt?.proof?.png ?? pt?.proof?.gif ?? null,
|
|
7718
8114
|
// Ledger before/after (docs/design/HANDOFF-review-card-evidence-first-2026-07-18.md
|
|
7719
8115
|
// §3 kind==='image'): most tasks never have a "before" capture, so this is almost
|
|
7720
8116
|
// always null and beforeAfterMediaHtml() falls back to the single-shot view.
|
|
7721
8117
|
proofBefore: pt?.proof?.beforePng ?? pt?.proof?.beforeGif ?? null,
|
|
8118
|
+
// Recording (spec §5/§6): a proof task may carry a screen recording (proof.mp4)
|
|
8119
|
+
// or just a recording:true flag; the goal's reviewSummary can also name one.
|
|
8120
|
+
// Drives the neutral "Verify recording ↗" meta chip + the Proof video player.
|
|
8121
|
+
// "ないものは載せない": almost all real tasks have none, so both stay hidden.
|
|
8122
|
+
recording: Boolean(pt?.proof?.mp4 || pt?.proof?.recording || g?.reviewSummary?.recording),
|
|
8123
|
+
recordingUrl: pt?.proof?.mp4 ? withKey(`/proof/${pt.id}/${pt.proof.mp4}`) : (typeof g?.reviewSummary?.recording === 'string' ? g.reviewSummary.recording : null),
|
|
8124
|
+
// Local URL (spec §5): a running localhost the reviewer can open ("Local localhost:4809 ↗").
|
|
8125
|
+
// Falls back to the existing "Local run ↗" (runlocal) button when absent.
|
|
8126
|
+
localUrl: g?.reviewSummary?.localUrl ?? null,
|
|
8127
|
+
// Client-side diff fallback: the git-backed /diff endpoint is null for seeded/
|
|
8128
|
+
// offline goals, so "What changed" never renders. A unified-diff string stored on
|
|
8129
|
+
// the goal (or any of its tasks) lets saGhDiffHtml render locally with no server.
|
|
8130
|
+
diffStr: g?.diff ?? (ts.filter((t) => r.goalIds.includes(t.goalId)).map((t) => t.diff).find(Boolean)) ?? null,
|
|
7722
8131
|
// Token-frugality is the product's whole pitch — surface it here too, not
|
|
7723
8132
|
// just in the per-goal review modal. buildReviewSummary computes the same
|
|
7724
8133
|
// estimate; reuse its logic directly off the goal's runUsage/runTokens.
|
|
7725
|
-
tokenOpt:
|
|
8134
|
+
tokenOpt: (typeof g?.reviewSummary?.token === 'number')
|
|
8135
|
+
? { percent: g.reviewSummary.token, basis: 'cache', techniques: ['prompt caching', '/compact'] }
|
|
8136
|
+
: estimateTokenOptimizationLocal({
|
|
7726
8137
|
usage: g?.runUsage, weightedTokens: g?.runTokens, cacheReadWeight: g?.runTokenWeight,
|
|
7727
8138
|
sessionMode: g?.executionPlan?.sessionMode,
|
|
7728
8139
|
usedHandoff: g?.executionPlan?.sessionMode === 'cold-handoff' || Boolean(g?.contextHandoff),
|
|
@@ -7808,8 +8219,82 @@ function saEvidenceHtml(kind, factRow, img, filePaths, diffText, gtr, it){
|
|
|
7808
8219
|
// kind === 'image' (default)
|
|
7809
8220
|
return `<div class="lg-ev"><div class="lg-evhd">Evidence</div><div class="row">${factRow}</div>${img}</div>`;
|
|
7810
8221
|
}
|
|
8222
|
+
// Unified-diff → GitHub two-gutter model (REVIEW-CARD-SPEC §7 "GitHub diff = Bright"):
|
|
8223
|
+
// walk the @@ hunk headers to recover the old/new line numbers, emit one row per line
|
|
8224
|
+
// {o:oldNo|null, n:newNo|null, t:'add'|'del'|'ctx', c:text}. Word-level highlight is
|
|
8225
|
+
// NOT fabricated from a raw diff (the product never invents evidence) — the Bright
|
|
8226
|
+
// line+gutter tint carries the change and .gh-hi simply stays absent. Pure, so it is
|
|
8227
|
+
// extracted and unit-tested in engine/test/review-card-diff.test.mjs.
|
|
8228
|
+
function saUnifiedToGh(diff, max){
|
|
8229
|
+
const cap = max || 80; const out = []; let o = 0, n = 0;
|
|
8230
|
+
for (const raw of String(diff || '').split('\n')) {
|
|
8231
|
+
if (raw === '') continue; // split artifact / blank input — a real blank ctx line is a single space
|
|
8232
|
+
if (/^(diff --git|index |--- |\+\+\+ |new file|deleted file|old mode|new mode|similarity|rename |Binary )/.test(raw)) continue;
|
|
8233
|
+
const m = raw.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
8234
|
+
if (m) { o = +m[1]; n = +m[2]; continue; }
|
|
8235
|
+
if (raw.startsWith('\\')) continue; // ""
|
|
8236
|
+
if (raw.startsWith('+')) out.push({ o: null, n: n++, t: 'add', c: raw.slice(1) });
|
|
8237
|
+
else if (raw.startsWith('-')) out.push({ o: o++, n: null, t: 'del', c: raw.slice(1) });
|
|
8238
|
+
else out.push({ o: o++, n: n++, t: 'ctx', c: raw.replace(/^ /, '') });
|
|
8239
|
+
if (out.length >= cap) break;
|
|
8240
|
+
}
|
|
8241
|
+
return out;
|
|
8242
|
+
}
|
|
8243
|
+
function saGhDiffHtml(diff){
|
|
8244
|
+
const rows = saUnifiedToGh(diff);
|
|
8245
|
+
if (!rows.length) return '';
|
|
8246
|
+
return saGhDiffRowsHtml(rows);
|
|
8247
|
+
}
|
|
8248
|
+
// One Activity transcript row (spec §3): You / tool (mono, bold name) / res (green) /
|
|
8249
|
+
// Claude. Mirrors the flagship artifact's tx mapping so the pulldown pixel-matches.
|
|
8250
|
+
function saTxRowHtml(e){
|
|
8251
|
+
if (e[0] === 'tool') return `<div class="txr tool"><span class="w">tool</span><span class="m"><b>${esc(e[1])}</b> ${esc(e[2] || '')}</span></div>`;
|
|
8252
|
+
if (e[0] === 'res') return `<div class="txr res"><span class="w"></span><span class="m">${esc(String(e[1]).replace(/^✗ /, '').replace(/^✓ /, ''))}</span></div>`;
|
|
8253
|
+
if (e[0] === 'user') return `<div class="txr you"><span class="w">You</span><span class="m">${esc(e[1])}</span></div>`;
|
|
8254
|
+
return `<div class="txr cl"><span class="w">Claude</span><span class="m">${esc(e[1])}</span></div>`;
|
|
8255
|
+
}
|
|
8256
|
+
// Structured GitHub diff (REVIEW-CARD-SPEC §7): renders {o,n,t,c,h} rows with a
|
|
8257
|
+
// word-level highlight (h) and, for a huge change, a "…+N 行 · GitHub で開く ↗"
|
|
8258
|
+
// footer (only linking to GitHub when a PR exists). Mirrors the flagship artifact's
|
|
8259
|
+
// ghDiffHtml so the app pixel-matches. `more` = extra unshown line count; `pr` = url.
|
|
8260
|
+
function saGhDiffRowsHtml(rows, more, pr){
|
|
8261
|
+
if (!rows || !rows.length) return '';
|
|
8262
|
+
const body = '<div class="rdiff2">' + rows.map((l) => {
|
|
8263
|
+
const cls = l.t === 'add' ? 'gh-add' : l.t === 'del' ? 'gh-del' : 'gh-ctx';
|
|
8264
|
+
const mk = l.t === 'add' ? '+' : l.t === 'del' ? '−' : '';
|
|
8265
|
+
let code = esc(l.c);
|
|
8266
|
+
if (l.h) { const h = esc(l.h); code = code.replace(h, `<span class="gh-hi">${h}</span>`); }
|
|
8267
|
+
return `<div class="ghl ${cls}"><span class="gho">${l.o ?? ''}</span><span class="ghn">${l.n ?? ''}</span><span class="ghm">${mk}</span><span class="ghc">${code}</span></div>`;
|
|
8268
|
+
}).join('') + '</div>';
|
|
8269
|
+
const foot = more ? `<div class="ghfoot"><span>…+${more} 行</span>${pr ? ` · <a class="ghopen" href="${esc(pr)}" target="_blank" rel="noopener">GitHub で開く ↗</a>` : ''}</div>` : '';
|
|
8270
|
+
return body + foot;
|
|
8271
|
+
}
|
|
8272
|
+
// Screen-recording player for Proof (REVIEW-CARD-SPEC §6). A real file (recordingUrl)
|
|
8273
|
+
// renders a native <video controls>; otherwise a self-contained SVG player mockup
|
|
8274
|
+
// (faint app frame · centred play · scrub bar) matching the artifact's videoFig, with
|
|
8275
|
+
// a "▸ Watch recording · 0:12" caption. Purely presentational — no fabricated evidence
|
|
8276
|
+
// (only rendered when a recording actually exists on the task/goal).
|
|
8277
|
+
function saVideoFigHtml(url){
|
|
8278
|
+
if (url) return `<figure class="rvid"><video class="proofvid" src="${esc(url)}" controls preload="metadata"></video><figcaption>▸ Watch recording</figcaption></figure>`;
|
|
8279
|
+
const P = 'var(--panel)', H = 'var(--hair)';
|
|
8280
|
+
const svg = `<svg viewBox="0 0 560 315" preserveAspectRatio="xMidYMid meet"><rect width="560" height="315" fill="${P}"/>`
|
|
8281
|
+
+ `<rect x="24" y="20" width="120" height="10" rx="3" fill="${H}"/><rect x="24" y="44" width="220" height="8" rx="3" fill="${H}"/><rect x="24" y="64" width="180" height="8" rx="3" fill="${H}"/>`
|
|
8282
|
+
+ `<circle cx="280" cy="165" r="34" fill="rgba(0,0,0,.35)"/><path d="M270 150 l24 15 l-24 15 z" fill="#fff"/>`
|
|
8283
|
+
+ `<rect x="24" y="286" width="512" height="4" rx="2" fill="${H}"/><rect x="24" y="286" width="150" height="4" rx="2" fill="#4fd79c"/><circle cx="174" cy="288" r="6" fill="#4fd79c"/></svg>`;
|
|
8284
|
+
return `<figure class="rvid">${svg}<figcaption>▸ Watch recording · 0:12</figcaption></figure>`;
|
|
8285
|
+
}
|
|
8286
|
+
// Workflow (REVIEW-CARD-SPEC §8): vertical ✓ / → / □ built ONLY from real signals on
|
|
8287
|
+
// this goal — each ✓ reflects something that actually happened (implemented, tested,
|
|
8288
|
+
// proof captured); the card's own "In review" is the → here; merge/deploy are ahead.
|
|
8289
|
+
// No fabricated pipeline: an untested goal shows □ Tested, not a green ✓.
|
|
8290
|
+
function saWorkflowHtml(it){
|
|
8291
|
+
const ran = Boolean(it.testResult?.ran);
|
|
8292
|
+
const hasProof = Boolean(it.proofImg && it.proofTaskId != null);
|
|
8293
|
+
const steps = [['Implemented', 'ok'], ['Tested', ran ? 'ok' : 'todo'], ['Proof captured', hasProof ? 'ok' : 'todo'], ['In review', 'now'], ['Merge', 'todo'], ['Deploy', 'todo']];
|
|
8294
|
+
return '<div class="rwf">' + steps.map(([s, st]) => `<div class="wf-s ${st}"><span class="mk">${st === 'ok' ? '✓' : st === 'now' ? '→' : '□'}</span>${s}</div>`).join('') + '</div>';
|
|
8295
|
+
}
|
|
7811
8296
|
function saItemHtml(it, i){
|
|
7812
|
-
const done = { 1: ['ok', '✓', 'Approved'], 2: ['no', '↩', 'Dismissed
|
|
8297
|
+
const done = { 1: ['ok', '✓', 'Approved'], 2: ['no', '↩', 'Dismissed'], 3: ['no', '↩', 'Feedback sent — returned to To Do'], 4: ['ar', '—', 'Archived'], 5: ['no', '↩', 'Reverted — change undone'] }[it.st];
|
|
7813
8298
|
if (done) {
|
|
7814
8299
|
// Revert deliberately has NO undo: the PR is closed and its branch deleted —
|
|
7815
8300
|
// an "UNDO" here would be a lie (HANDOFF-v45 §5.3 honesty over symmetry).
|
|
@@ -7817,70 +8302,162 @@ function saItemHtml(it, i){
|
|
|
7817
8302
|
return `<div class="sa-done ${done[0]}"><span class="mk">${done[1]}</span>${done[2]} · <span style="color:var(--ink3)">${esc(it.title.slice(0, 44))}</span>${undo}</div>`;
|
|
7818
8303
|
}
|
|
7819
8304
|
const d = SA.diffs[it.goalId];
|
|
7820
|
-
// Honest evidence chips (mirrors the vchip tested/proof/needs-you logic in
|
|
7821
|
-
// renderReviewChecklist ~L3742-3752): absence of a signal is shown as a muted
|
|
7822
|
-
// chip, never silently dropped — an untested/proofless goal must never LOOK verified.
|
|
7823
|
-
// H23: facts stop looking like buttons — glyph carries the state colour, text
|
|
7824
|
-
// stays plain ink, no border/pill (border is reserved for the PR link).
|
|
7825
8305
|
const gtr = it.testResult;
|
|
7826
|
-
const testsChip = gtr?.ran
|
|
7827
|
-
? (gtr.ok ? `<span class="vf ok"><i class="g">✓</i>${gtr.passed} passed</span>` : `<span class="vf no"><i class="g">✗</i>${gtr.passed}/${gtr.passed + (gtr.failed || 0)} passed</span>`)
|
|
7828
|
-
: '<span class="vf none">— not tested</span>';
|
|
7829
8306
|
const hasProofImg = it.proofImg && it.proofTaskId != null;
|
|
7830
|
-
// Before/after (
|
|
7831
|
-
//
|
|
7832
|
-
//
|
|
7833
|
-
//
|
|
7834
|
-
//
|
|
7835
|
-
// after the card first paints (saEnhanceDiffBoxes, below saRender). SA.diffBoxHtml
|
|
7836
|
-
// holds the result — a string once a box is found, null once ruled out, undefined
|
|
7837
|
-
// until then — so it survives every later saRender() the same way SA.diffs already
|
|
7838
|
-
// does for the changed-files diff.
|
|
8307
|
+
// Before/after (spec §6): reuse the same beforeafter/ba-col/balabel renderer as the
|
|
8308
|
+
// per-task checklist (beforeAfterMediaHtml). it.proofBefore is null for the ~all
|
|
8309
|
+
// tasks with no "before" capture, so it falls back to the single <img class="proofimg">.
|
|
8310
|
+
// Diff-box + zoom inset is computed async after first paint (saEnhanceDiffBoxes);
|
|
8311
|
+
// SA.diffBoxHtml caches it so the box survives every later saRender().
|
|
7839
8312
|
const boxedImg = SA.diffBoxHtml[it.goalId];
|
|
7840
8313
|
const img = hasProofImg
|
|
7841
|
-
? (typeof boxedImg === 'string' ? boxedImg : beforeAfterMediaHtml(it.proofTaskId, { png: it.proofImg, beforePng: it.proofBefore }, '
|
|
8314
|
+
? (typeof boxedImg === 'string' ? boxedImg : beforeAfterMediaHtml(it.proofTaskId, { png: it.proofImg, beforePng: it.proofBefore }, esc(it.title ?? '')))
|
|
7842
8315
|
: '';
|
|
7843
|
-
// Changed-file paths
|
|
7844
|
-
//
|
|
7845
|
-
//
|
|
8316
|
+
// Changed-file paths: d from the diff endpoint ({add,del,files:[{path,add,del}],diff}),
|
|
8317
|
+
// falling back to task.changedFiles when the server has no diff for this goal.
|
|
8318
|
+
// A usable git diff has actual files; the endpoint returns an empty {add:0,del:0,
|
|
8319
|
+
// files:[],diff:''} object for goals with no committed range (seeded/uncommitted),
|
|
8320
|
+
// which must NOT shadow the client-side fallback below.
|
|
8321
|
+
const hasGitDiff = Boolean(d && d.diff);
|
|
7846
8322
|
let filePaths = [], nf = 0;
|
|
7847
|
-
if (d) { filePaths = d.files.map((f) => f.path); nf = filePaths.length; }
|
|
7848
|
-
else
|
|
8323
|
+
if (d && d.files.length) { filePaths = d.files.map((f) => f.path); nf = filePaths.length; }
|
|
8324
|
+
else {
|
|
8325
|
+
// no usable git diff: fall back to the tasks' own changedFiles so Files / What
|
|
8326
|
+
// changed still populate for seeded/offline goals.
|
|
7849
8327
|
filePaths = [...new Set((state.tasks || []).filter((t) => it.ids.includes(t.goalId)).flatMap((t) => t.changedFiles ?? []))];
|
|
7850
8328
|
nf = filePaths.length;
|
|
7851
8329
|
}
|
|
8330
|
+
// Client-side unified diff (spec §4 fallback): render "What changed" locally when the
|
|
8331
|
+
// server has no usable git diff. Line totals feed the Files chip's +add / −del.
|
|
8332
|
+
const clientDiff = (!hasGitDiff && it.diffStr) ? it.diffStr : null;
|
|
8333
|
+
let cAdd = 0, cDel = 0;
|
|
8334
|
+
if (clientDiff) for (const l of saUnifiedToGh(clientDiff, 2000)) { if (l.t === 'add') cAdd++; else if (l.t === 'del') cDel++; }
|
|
8335
|
+
// Recorded display fields (spec §11: card shape follows the task). A goal's reviewSummary
|
|
8336
|
+
// may carry an explicit file list; prefer it (real git-backed goals keep the computed
|
|
8337
|
+
// filePaths above). Split the "path +N −M" string down to the bare path for kind/log.
|
|
8338
|
+
if (it.metaFiles) { filePaths = it.metaFiles.map((f) => String(f).split(/\s{2,}/)[0]); nf = it.metaFiles.length; }
|
|
7852
8339
|
const kind = saEvidenceKind(filePaths, hasProofImg, gtr);
|
|
7853
|
-
const factRow = [
|
|
7854
|
-
testsChip,
|
|
7855
|
-
it.note ? `<span class="vf ok"><i class="g">✓</i>${esc(it.note)}</span>` : '',
|
|
7856
|
-
`<span class="fls">${nf} file${nf === 1 ? '' : 's'}</span>`,
|
|
7857
|
-
hasProofImg ? '' : '<span class="vf none">— no proof</span>',
|
|
7858
|
-
].filter(Boolean).join('');
|
|
7859
8340
|
const gk = it.goalId;
|
|
7860
|
-
|
|
7861
|
-
//
|
|
7862
|
-
//
|
|
7863
|
-
const
|
|
7864
|
-
|
|
7865
|
-
|
|
7866
|
-
|
|
7867
|
-
|
|
7868
|
-
|
|
7869
|
-
|
|
7870
|
-
|
|
8341
|
+
const rs = it.goal?.reviewSummary || {};
|
|
8342
|
+
// Conversational task (no code change): drops Proof + What changed, shows an Answer
|
|
8343
|
+
// instead (spec §11); its Activity IS the conversation.
|
|
8344
|
+
const isConv = Boolean(it.answer);
|
|
8345
|
+
// ── meta (spec §5) — fully data-driven / hide-empty: each chip renders only when its
|
|
8346
|
+
// data exists (no PR → no PR chip · no tests → no Tests · no file totals → no Files ·
|
|
8347
|
+
// no recording → no Verify). PR = bold green, no underline (a real <a href>). ──
|
|
8348
|
+
const arw = '<span class="ar">↗</span>';
|
|
8349
|
+
const prNum = it.pr ? (it.pr.match(/pull\/(\d+)/) ?? [, ''])[1] : '';
|
|
8350
|
+
const tokPct = it.tokenOpt ? Number(it.tokenOpt.percent) : 0;
|
|
8351
|
+
const testsLabel = gtr?.ran ? (gtr.label || (gtr.ok ? `${gtr.passed} passed` : `${gtr.passed}/${gtr.passed + (gtr.failed || 0)} passed`)) : '';
|
|
8352
|
+
// Files totals: recorded strings (metaAdd/metaDel, already signed) win, else git/client.
|
|
8353
|
+
const fAdd = it.metaAdd ?? (hasGitDiff ? `+${d.add}` : clientDiff ? `+${cAdd}` : null);
|
|
8354
|
+
const fDel = it.metaDel ?? (hasGitDiff ? `−${d.del}` : clientDiff ? `−${cDel}` : null);
|
|
8355
|
+
const chips = [];
|
|
8356
|
+
if (it.pr) chips.push(`<a class="mx mlink pr" href="${esc(it.pr)}" target="_blank" rel="noopener" title="Open pull request"><span class="mk">PR</span> <b>#${esc(prNum)}</b> <span class="mmut">Open</span>${arw}</a>`);
|
|
8357
|
+
if (it.tokenOpt && tokPct > 0) chips.push(`<button type="button" class="mx mtok" data-saact="tok:${i}"><span class="mk">Token</span> <b>${tokPct}%</b> <span class="mmut">saved</span> <i class="tokchevron">›</i></button>`);
|
|
8358
|
+
if (testsLabel) chips.push(`<span class="mx"><span class="mk">Tests</span> <b class="ok">${esc(testsLabel)}</b></span>`);
|
|
8359
|
+
// Files: hide-empty — only with changed files AND totals (a bare-count chip reads odd).
|
|
8360
|
+
if (nf && fAdd) chips.push(`<span class="mx"><span class="mk">Files</span> <b>${nf}</b> <span class="mmut">· <i class="a">${esc(fAdd)}</i> <i class="d">${esc(fDel)}</i></span></span>`);
|
|
8361
|
+
// Local: an open localhost URL (neutral, not green). A bare host:port isn't navigable in
|
|
8362
|
+
// the demo, so it renders as a non-navigating chip; a real URL opens in a new tab.
|
|
8363
|
+
if (it.localUrl) { const lu = String(it.localUrl); const isUrl = /^https?:/.test(lu);
|
|
8364
|
+
chips.push(`<a class="mx mlink"${isUrl ? ` href="${esc(lu)}" target="_blank" rel="noopener"` : ' style="cursor:pointer"'} title="Open the running app"><span class="mk">Local</span> <b>${esc(lu.replace(/^https?:\/\//, ''))}</b>${arw}</a>`); }
|
|
8365
|
+
else if (gtr?.ran) chips.push(`<button type="button" class="mx mlink" data-saact="runlocal:${i}" title="Run this locally"><span class="mk">Local</span> <b>run</b>${arw}</button>`);
|
|
8366
|
+
// Verify = a screen recording of the check (spec §5): NEUTRAL. Opens the file when there
|
|
8367
|
+
// is a real URL, otherwise scrolls to the in-card player.
|
|
8368
|
+
if (it.recording) chips.push(it.recordingUrl
|
|
8369
|
+
? `<a class="mx mlink" href="${esc(it.recordingUrl)}" target="_blank" rel="noopener" title="Watch the recording"><span class="mk">Verify</span> <b>recording</b>${arw}</a>`
|
|
8370
|
+
: `<a class="mx mlink" data-saact="rec:${i}" style="cursor:pointer" title="Watch the recording"><span class="mk">Verify</span> <b>recording</b>${arw}</a>`);
|
|
8371
|
+
const tokCmp = it.tokenOpt?.basis === 'baseline' ? 'vs your recent average' : 'from prompt caching';
|
|
8372
|
+
const tokTech = (it.tokenOpt?.techniques ?? []).slice(0, 4).join(' / ');
|
|
8373
|
+
const metaDetail = (it.tokenOpt && tokPct > 0) ? `<div class="rmeta-detail" id="tokdetail${i}"><span class="k">How this run used tokens</span><span class="v">${esc(tokCmp)}${tokTech ? ` · ${esc(tokTech)}` : ''}</span></div>` : '';
|
|
8374
|
+
const runNote = gtr?.ran ? `<div class="rmeta-detail" id="runlocal${i}"><span class="v">No local run command is wired for this task yet.</span></div>` : '';
|
|
8375
|
+
const meta = `<div class="rmeta">${chips.join('')}</div>${metaDetail}${runNote}`;
|
|
8376
|
+
// ── Request (spec §4): a PLAIN section — the user's own short words (goal.text). Never
|
|
8377
|
+
// an accordion, never the implementation spec (that lives in Activity below). ──
|
|
8378
|
+
const reqSec = it.goalText
|
|
8379
|
+
? `<section class="rsec rreq"><div class="rsec-h">Request</div><p class="rbody rask">${esc(it.goalText)}</p></section>`
|
|
8380
|
+
: '';
|
|
8381
|
+
// ── What to check (spec §2.5): a bulleted list when several, a line when one. ──
|
|
8382
|
+
// Only an explicit review-summary check renders here — never the goal text echoed back
|
|
8383
|
+
// (it.check falls back to goal.text via checkLine, which would fabricate a "What to
|
|
8384
|
+
// check" for a conversational task that has none). §0.5 no placeholder.
|
|
8385
|
+
const checkArr = Array.isArray(rs.checkList) ? rs.checkList.filter(Boolean)
|
|
8386
|
+
: Array.isArray(rs.check) ? rs.check.filter(Boolean)
|
|
8387
|
+
: (typeof rs.check === 'string' && rs.check.trim() ? [rs.check.trim()] : []);
|
|
8388
|
+
const checkBody = checkArr.length > 1
|
|
8389
|
+
? `<ul class="rcheck">${checkArr.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>`
|
|
8390
|
+
: (checkArr.length ? `<p class="rbody rcheck1">${esc(checkArr[0])}</p>` : '');
|
|
8391
|
+
// ── Proof (spec §6/§11): before/after (identical size) when a real image exists; a
|
|
8392
|
+
// run-log for logic-only tests; dropped entirely for a conversational task. ──
|
|
8393
|
+
let proofInner = '';
|
|
8394
|
+
if (!isConv) {
|
|
8395
|
+
if (hasProofImg) proofInner = `<div class="rproof">${img}</div>`;
|
|
8396
|
+
else if (kind === 'logic' && gtr?.ran) proofInner = `<div class="rrun"><div class="rr"><span class="lab">Tests</span><span class="v">${esc(testsLabel)}</span></div>${filePaths.length ? `<div class="rr"><span class="lab">Files</span><span class="v">${esc(filePaths.join(', '))}</span></div>` : ''}</div>`;
|
|
8397
|
+
if (it.recording) proofInner += saVideoFigHtml(it.recordingUrl);
|
|
8398
|
+
}
|
|
8399
|
+
// ── What changed (spec §4/§7): description + files summary (always) + the diff, which
|
|
8400
|
+
// peeks then "…+N 行 · GitHub で開く ↗" for a huge change (no GitHub link when there's no
|
|
8401
|
+
// PR). Structured recorded rows (ghRows) win over a git/client unified diff. Dropped
|
|
8402
|
+
// entirely for a conversational task. ──
|
|
8403
|
+
const changedDescHtml = (it.changedDesc || rs.changed) ? `<p class="rbody rdesc">${esc(String(it.changedDesc || rs.changed).slice(0, 600))}</p>` : '';
|
|
8404
|
+
const filesListHtml = it.metaFiles
|
|
8405
|
+
? `<div class="rfiles">${it.metaFiles.map((f) => esc(f)).join('<br>')}</div>`
|
|
8406
|
+
: (d && d.files.length) ? `<div class="rfiles">${d.files.map((f) => `${esc(f.path)} <i class="a">+${f.add}</i> <i class="d">−${f.del}</i>`).join('<br>')}</div>`
|
|
8407
|
+
: (clientDiff && filePaths.length) ? `<div class="rfiles">${filePaths.map((p) => esc(p)).join('<br>')}</div>` : '';
|
|
8408
|
+
const diffHtml = it.ghRows
|
|
8409
|
+
? saGhDiffRowsHtml(it.ghRows, it.diffTotal || 0, it.pr || '')
|
|
8410
|
+
: hasGitDiff ? saGhDiffHtml(d.diff) : clientDiff ? saGhDiffHtml(clientDiff) : '';
|
|
8411
|
+
const changedBody = isConv ? '' : (changedDescHtml + filesListHtml + diffHtml);
|
|
8412
|
+
// ── sensitive-area note (real: classifyChangeRisk forced the pause) ──
|
|
7871
8413
|
const risk = it.risk;
|
|
7872
8414
|
const riskHtml = risk?.shouldPause
|
|
7873
8415
|
? `<div class="lg-risk"><svg viewBox="0 0 24 24"><path d="M12 8v5M12 16.5v.5"/><path d="M10.3 3.9 2.4 18a1.9 1.9 0 0 0 1.7 2.9h15.8a1.9 1.9 0 0 0 1.7-2.9L13.7 3.9a1.9 1.9 0 0 0-3.4 0z"/></svg>Needs your review — touches a sensitive area${risk.categories?.length ? `: ${esc(risk.categories.map((c) => c.label).join(', '))}` : ''}</div>`
|
|
7874
8416
|
: '';
|
|
7875
|
-
|
|
7876
|
-
|
|
7877
|
-
|
|
7878
|
-
|
|
7879
|
-
|
|
8417
|
+
// ── Activity (spec §3): ONE Linear-docs pulldown. The "Activity" heading is plain; the
|
|
8418
|
+
// chevron sits on the FIRST line (the opening "You …" row); tapping it opens the rest
|
|
8419
|
+
// below, offset-indented. The review Discussion folds in at the bottom of the body. The
|
|
8420
|
+
// transcript is the recorded build (activity); real goals fall back to "what we did".
|
|
8421
|
+
// Persisted open-state via data-detkey so SSE re-renders don't collapse it. ──
|
|
8422
|
+
const txArr = (it.activity && it.activity.length)
|
|
8423
|
+
? it.activity.map((e) => saTxRowHtml(e))
|
|
8424
|
+
: (it.did?.length ? it.did.map((s) => saTxRowHtml(['claude', s])) : []);
|
|
8425
|
+
const discArr = it.discussion || it.thread || null;
|
|
8426
|
+
const threadHtml = (discArr && discArr.length)
|
|
8427
|
+
? discArr.map((m) => `<div class="rmsg${m[0] === 'you' ? ' you' : ''}"><div class="who">${m[0] === 'you' ? 'You' : 'Manager'}</div><div class="msg">${esc(m[1])}</div></div>`).join('')
|
|
8428
|
+
: '';
|
|
8429
|
+
const actOpen = SA.detOpen.has(`${gk}:act`) ? ' open' : '';
|
|
8430
|
+
const txFirst = txArr[0] || '<div class="txr"><span class="w"></span><span class="m">Activity</span></div>';
|
|
8431
|
+
const txRest = txArr.slice(1).join('');
|
|
8432
|
+
const discInner = threadHtml ? `<div class="rsub">Discussion</div><div class="rthread">${threadHtml}</div>` : '';
|
|
8433
|
+
const activitySec = (txArr.length || threadHtml)
|
|
8434
|
+
? `<section class="rsec ract"><div class="rsec-h">Activity</div><details class="lacc" data-detkey="${gk}:act"${actOpen}><summary><i class="ltog">›</i><div class="rtx lfirst">${txFirst}</div></summary><div class="lbody">${txRest ? `<div class="rtx">${txRest}</div>` : ''}${discInner}</div></details></section>`
|
|
8435
|
+
: '';
|
|
8436
|
+
const S = (label, body) => body ? `<section class="rsec"><div class="rsec-h">${label}</div>${body}</section>` : '';
|
|
8437
|
+
return `<div class="sa-item lg redesign${it.goalId === SA.cur ? ' cur' : ''}" data-gid="${it.goalId}">
|
|
8438
|
+
<div class="rhead"><span class="rtag">${it.no != null ? `#${it.no}` : ''}</span><span class="rst"><span class="rdot"></span>In review</span></div>
|
|
8439
|
+
<h2 class="rtitle">${esc(it.title)}</h2>
|
|
8440
|
+
${meta}
|
|
8441
|
+
${reqSec}
|
|
8442
|
+
${S('What to check', checkBody)}
|
|
8443
|
+
${S('Answer', it.answer ? `<p class="rbody">${esc(it.answer)}</p>` : '')}
|
|
8444
|
+
${S('Proof', proofInner)}
|
|
8445
|
+
${S('What changed', changedBody)}
|
|
8446
|
+
${S('Not done', it.risk?.summary ? `<p class="rbody rrisk">${esc(it.risk.summary)}</p>` : '')}
|
|
7880
8447
|
${riskHtml}
|
|
7881
|
-
|
|
8448
|
+
${activitySec}
|
|
8449
|
+
${S('Workflow', saWorkflowHtml(it))}
|
|
8450
|
+
<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="Reply, ask, or send back to the AI…"><button class="sa-send" data-saact="send:${i}" title="Send"><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>
|
|
7882
8451
|
</div>`;
|
|
7883
8452
|
}
|
|
8453
|
+
// Scroll the See-all body so a card's top edge (.rhead) sits at the top of the visible
|
|
8454
|
+
// scroll area — the reviewer always starts reading a card from its header, never partway
|
|
8455
|
+
// down (Masa 2026-07-20). Uses a rect delta so it works regardless of offsetParent.
|
|
8456
|
+
function saScrollCardToTop(el){
|
|
8457
|
+
const body = document.querySelector('#seeall .sa-body');
|
|
8458
|
+
if (!body || !el) return;
|
|
8459
|
+
body.scrollTop += el.getBoundingClientRect().top - body.getBoundingClientRect().top;
|
|
8460
|
+
}
|
|
7884
8461
|
function saRender(){
|
|
7885
8462
|
const S = $('seeall'); if (!S) return;
|
|
7886
8463
|
const pending = SA.items.filter((x) => x.st === 0);
|
|
@@ -7894,22 +8471,32 @@ function saRender(){
|
|
|
7894
8471
|
// Native <details> would collapse on every rebuild (SSE tick, approve, etc.) —
|
|
7895
8472
|
// track what's open per goal+section in SA.detOpen (persists across re-renders)
|
|
7896
8473
|
// and wire each one's toggle to keep that set current.
|
|
7897
|
-
for (const det of S.querySelectorAll('
|
|
8474
|
+
for (const det of S.querySelectorAll('[data-detkey]')) {
|
|
7898
8475
|
det.addEventListener('toggle', () => {
|
|
7899
8476
|
const k = det.dataset.detkey;
|
|
7900
8477
|
if (det.open) SA.detOpen.add(k); else SA.detOpen.delete(k);
|
|
7901
8478
|
});
|
|
7902
8479
|
}
|
|
7903
|
-
//
|
|
7904
|
-
//
|
|
7905
|
-
//
|
|
7906
|
-
//
|
|
7907
|
-
|
|
8480
|
+
// "ないものは載せない" (Masa): a Proof <img> whose file is missing/broken must never
|
|
8481
|
+
// show a broken-image placeholder. proofImg is only a stored filename, so a file that
|
|
8482
|
+
// isn't on disk (or fails to load) can only be caught at load time — drop the whole
|
|
8483
|
+
// Proof section if any of its images fails. Real captures load and stay.
|
|
8484
|
+
for (const im of S.querySelectorAll('.redesign .rproof img')) {
|
|
8485
|
+
const drop = () => { const sec = im.closest('section.rsec'); if (sec) sec.remove(); };
|
|
8486
|
+
if (im.complete && im.naturalWidth === 0) drop();
|
|
8487
|
+
else im.addEventListener('error', drop, { once: true });
|
|
8488
|
+
}
|
|
8489
|
+
// H14: if a single-review focus is pending, bring the card's HEADER (#tag + In review
|
|
8490
|
+
// + title) to the very top of the scroll area — never mid-card (Masa 2026-07-20) — and
|
|
8491
|
+
// flash a blue ring. Re-scroll on EVERY render for the focus window — the post-diff
|
|
8492
|
+
// saSync rebuild resets .sa-body scroll to the top, so a one-shot scroll would land back
|
|
8493
|
+
// there. Self-clears after ~1.4s (outlasts the localhost diff fetch) so live SSE ticks
|
|
8494
|
+
// stop yanking the view.
|
|
7908
8495
|
if (SA.focusGid != null) {
|
|
7909
8496
|
requestAnimationFrame(() => {
|
|
7910
8497
|
const el = S.querySelector(`.sa-item[data-gid="${SA.focusGid}"]`);
|
|
7911
8498
|
if (!el) return;
|
|
7912
|
-
el
|
|
8499
|
+
saScrollCardToTop(el);
|
|
7913
8500
|
el.classList.add('sa-flash');
|
|
7914
8501
|
if (!SA.focusTimer) SA.focusTimer = setTimeout(() => { SA.focusGid = null; SA.focusTimer = null; }, 1400);
|
|
7915
8502
|
});
|
|
@@ -7980,6 +8567,14 @@ function saOpen(focusGoalId){
|
|
|
7980
8567
|
}
|
|
7981
8568
|
saRender();
|
|
7982
8569
|
$('seeall').classList.add('open');
|
|
8570
|
+
// Land the opening card's header at the very top of the scroll area (Masa 2026-07-20).
|
|
8571
|
+
// With focusGid set, saRender's focus block already does this; without it (a plain
|
|
8572
|
+
// "See all"), resolved one-liners can sit above the first pending card, so scrollTop 0
|
|
8573
|
+
// isn't enough — scroll the current card's header to the top explicitly.
|
|
8574
|
+
if (SA.focusGid == null && SA.cur != null) requestAnimationFrame(() => {
|
|
8575
|
+
const el = $('seeall').querySelector(`.sa-item[data-gid="${SA.cur}"]`);
|
|
8576
|
+
if (el) saScrollCardToTop(el);
|
|
8577
|
+
});
|
|
7983
8578
|
saFetchDiffs();
|
|
7984
8579
|
saEnhanceDiffBoxes();
|
|
7985
8580
|
}
|
|
@@ -8031,7 +8626,7 @@ async function saAct(it, st, path, body){
|
|
|
8031
8626
|
saSync();
|
|
8032
8627
|
return true;
|
|
8033
8628
|
}
|
|
8034
|
-
const SA_UNDO = { 1: 'revert-review', 2: '
|
|
8629
|
+
const SA_UNDO = { 1: 'revert-review', 2: 'reopen', 3: 'undismiss', 4: 'unarchive' }; // 07-20c: st 2 = Reject, undo = reopen
|
|
8035
8630
|
$('seeall').addEventListener('mousedown', (e) => { if (e.target === $('seeall')) saClose(); }); // no-dim dialog: outside = close
|
|
8036
8631
|
$('seeall').addEventListener('click', async (e) => {
|
|
8037
8632
|
const b = e.target.closest('[data-saact]');
|
|
@@ -8044,9 +8639,17 @@ $('seeall').addEventListener('click', async (e) => {
|
|
|
8044
8639
|
// Run-locally to reveal the honest "not wired yet" note — neither performs an action.
|
|
8045
8640
|
if (k === 'tok') { b.classList.toggle('open'); $('seeall').querySelector(`#tokdetail${i}`)?.classList.toggle('open'); return; }
|
|
8046
8641
|
if (k === 'runlocal') { $('seeall').querySelector(`#runlocal${i}`)?.classList.toggle('open'); return; }
|
|
8642
|
+
// Verify chip (no external recording URL) → scroll the in-card video player into view.
|
|
8643
|
+
if (k === 'rec') { $('seeall').querySelector(`.sa-item[data-gid="${it.goalId}"] .rvid`)?.scrollIntoView({ behavior: 'smooth', block: 'center' }); return; }
|
|
8047
8644
|
// every verdict auto-advances the focus to the next pending card (tempo).
|
|
8048
|
-
if (k === 'app') { const bx = b.getBoundingClientRect();
|
|
8049
|
-
|
|
8645
|
+
if (k === 'app') { const bx = b.getBoundingClientRect(); return saAct(it, 1, 'approve').then((ok) => {
|
|
8646
|
+
if (!ok) return;
|
|
8647
|
+
// 07-20c: if the goal has a PR it stays in review (needsMerge) — open the merge dialog
|
|
8648
|
+
// instead of celebrating/advancing; saCall already upserted the still-'review' goal.
|
|
8649
|
+
const g = state.goals.find((x) => x.id === it.goalId);
|
|
8650
|
+
if (g && g.status === 'review' && g.pr) { confirmMerge(g); return; }
|
|
8651
|
+
confettiBurst(bx.left + bx.width / 2, bx.top + bx.height / 2); saAdvanceFocus(it.goalId); }); }
|
|
8652
|
+
if (k === 'dis') return saAct(it, 2, 'reject').then((ok) => { if (ok) saAdvanceFocus(it.goalId); }); // 07-20c: Reject → Rejected column
|
|
8050
8653
|
if (k === 'arch') return saAct(it, 4, 'archive').then((ok) => { if (ok) saAdvanceFocus(it.goalId); });
|
|
8051
8654
|
if (k === 'rev') return saAct(it, 5, 'revert').then((ok) => { if (ok) saAdvanceFocus(it.goalId); });
|
|
8052
8655
|
if (k === 'send') {
|
|
@@ -8090,11 +8693,16 @@ document.addEventListener('keydown', (e) => {
|
|
|
8090
8693
|
e.preventDefault();
|
|
8091
8694
|
const gid = it.goalId;
|
|
8092
8695
|
if (e.key === 'a' || e.key === 'A') {
|
|
8093
|
-
|
|
8094
|
-
|
|
8095
|
-
|
|
8096
|
-
|
|
8097
|
-
|
|
8696
|
+
saAct(it, 1, 'approve').then((ok) => {
|
|
8697
|
+
if (!ok) return;
|
|
8698
|
+
const g = state.goals.find((x) => x.id === gid);
|
|
8699
|
+
if (g && g.status === 'review' && g.pr) { confirmMerge(g); return; } // 07-20c: PR goal → merge dialog
|
|
8700
|
+
const el = $('seeall').querySelector(`.sa-item[data-gid="${gid}"] .sa-app`);
|
|
8701
|
+
const box = el?.getBoundingClientRect();
|
|
8702
|
+
confettiBurst(box ? box.left + box.width / 2 : innerWidth / 2, box ? box.top + box.height / 2 : innerHeight / 3);
|
|
8703
|
+
saAdvanceFocus(gid);
|
|
8704
|
+
});
|
|
8705
|
+
} else saAct(it, 2, 'reject').then((ok) => { if (ok) saAdvanceFocus(gid); }); // 07-20c: D = Reject
|
|
8098
8706
|
});
|
|
8099
8707
|
// 紙吹雪 (Approveの気持ちよさ=機能要件・PRD §5.1) — flagship confetti port:
|
|
8100
8708
|
// 6 full-spectrum colours, ~90 small rects, gravity, gone in 1.4s.
|
|
@@ -8403,7 +9011,7 @@ let _delConfirmResolve = null;
|
|
|
8403
9011
|
// Discard positioned left); dismissing the dialog must still land on the non-destructive choice
|
|
8404
9012
|
// regardless of which label happens to sit in which slot.
|
|
8405
9013
|
let _delDismissValue = false;
|
|
8406
|
-
function confirmDelete({ title, body, preview, confirmLabel, cancelLabel, dismissValue = false, confirmDanger = true, cancelDanger = false } = {}){
|
|
9014
|
+
function confirmDelete({ title, body, preview, previewHtml, previewPlain = false, confirmLabel, cancelLabel, dismissValue = false, confirmDanger = true, cancelDanger = false } = {}){
|
|
8407
9015
|
$('delModalTitle').textContent = title || 'Delete this goal?';
|
|
8408
9016
|
$('delModalBody').textContent = body || "This can't be undone.";
|
|
8409
9017
|
$('delConfirmBtn').textContent = confirmLabel || 'Delete';
|
|
@@ -8415,7 +9023,10 @@ function confirmDelete({ title, body, preview, confirmLabel, cancelLabel, dismis
|
|
|
8415
9023
|
$('delConfirmBtn').className = 'mbtn' + (confirmDanger ? ' danger' : ' primary');
|
|
8416
9024
|
$('delCancelBtn').className = 'mbtn' + (cancelDanger ? ' danger' : ' ghost');
|
|
8417
9025
|
const pv = $('delPreviewTitle');
|
|
8418
|
-
|
|
9026
|
+
pv.classList.toggle('plain', !!previewPlain);
|
|
9027
|
+
if (previewHtml) { pv.innerHTML = previewHtml; pv.style.display = ''; }
|
|
9028
|
+
else if (preview) { pv.textContent = preview; pv.style.display = ''; }
|
|
9029
|
+
else { pv.textContent = ''; pv.style.display = 'none'; }
|
|
8419
9030
|
_delDismissValue = dismissValue;
|
|
8420
9031
|
$('deleteConfirmOverlay').classList.add('show');
|
|
8421
9032
|
return new Promise((resolve) => { _delConfirmResolve = resolve; });
|
|
@@ -9152,7 +9763,6 @@ function gearSetHTML(){
|
|
|
9152
9763
|
// to anonymous free users too. Sign-in is an extra (sync across devices), not a
|
|
9153
9764
|
// gate to see your own usage or upgrade.
|
|
9154
9765
|
const freeUsage = `<div class="srow"><div class="sl">Plan</div><span class="bx-sval">Free</span></div>
|
|
9155
|
-
<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>
|
|
9156
9766
|
<div class="srow"><div class="sl">Lifetime</div><span class="bx-sval bx-num">${u.cumulative ?? 0} / ${lim.cumulative ?? 20}</span></div>
|
|
9157
9767
|
<button class="bx-cta bx-up-set" data-gupgrade="1"><span class="bx-spin"></span>Upgrade${priceTag(bill.price)}</button>`;
|
|
9158
9768
|
const billingRow = bill.plan === 'paid'
|