@galda/cli 0.10.34 → 0.10.36
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 +475 -107
- package/bin/manager-for-ai.mjs +12 -2
- package/engine/lib.mjs +119 -1
- package/engine/relay-supervisor.mjs +113 -0
- package/engine/server.mjs +275 -38
- package/engine/verify.mjs +9 -2
- package/package.json +1 -1
package/app/index.html
CHANGED
|
@@ -452,6 +452,18 @@
|
|
|
452
452
|
.vchip.none{color:var(--ink3);border-color:var(--hair2)}
|
|
453
453
|
.vchip.human{color:var(--amber);border-color:rgba(var(--amber-rgb),.5);font-weight:700}
|
|
454
454
|
.vchip.human.high{color:var(--danger);border-color:rgba(var(--danger-rgb),.5)}
|
|
455
|
+
/* H23 (2026-07-18) evidence-first review card: a fact stops looking like a button —
|
|
456
|
+
glyph carries the state colour, text stays plain ink, no border/pill (border is
|
|
457
|
+
reserved for the PR link, the card's only clickable pill). Scoped to .lg-ev so it
|
|
458
|
+
only affects the Ledger review card; other .vchip call sites are unchanged/out of
|
|
459
|
+
scope for this pass. Ported from flagship.html .lg-ev .vf (~L401-407). */
|
|
460
|
+
.lg-ev .vf{display:inline-flex;align-items:center;gap:5px;color:var(--ink2)}
|
|
461
|
+
.lg-ev .vf .g{font-style:normal}
|
|
462
|
+
.lg-ev .vf.ok .g{color:var(--green)}
|
|
463
|
+
.lg-ev .vf.no .g{color:var(--danger)}
|
|
464
|
+
.lg-ev .vf.none{color:var(--ink3)}
|
|
465
|
+
.lg-ev .vf.human .g{color:var(--amber)}
|
|
466
|
+
.lg-ev .vf.human.high .g{color:var(--danger)}
|
|
455
467
|
.pbadge{font-family:var(--mono);font-size:9.5px;padding:1px 6px;border-radius:5px;margin-right:6px;color:var(--iri);border:1px solid rgba(var(--blue-rgb),.35)}
|
|
456
468
|
.blockreason{color:var(--amber)}
|
|
457
469
|
.rvcheck2{font-size:15px;font-weight:600;color:var(--ink);line-height:1.5;margin-bottom:4px}
|
|
@@ -829,6 +841,14 @@
|
|
|
829
841
|
.beforeafter{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
|
830
842
|
.ba-col{min-width:0}
|
|
831
843
|
.balabel{display:block;font-family:var(--mono);font-size:9px;letter-spacing:.06em;color:var(--ink3);margin-bottom:2px}
|
|
844
|
+
/* diff box + zoomed inset (HANDOFF-review-card-diffbox-2026-07-18) — only emitted
|
|
845
|
+
by beforeAfterBoxedHtml(), so these never touch the plain pair above or the
|
|
846
|
+
older per-task renderReviewChecklist before/after (out of scope, untouched). */
|
|
847
|
+
.ba-imgwrap{position:relative;margin-top:8px}
|
|
848
|
+
.ba-imgwrap .proofimg{width:100%;height:auto;max-height:none;object-fit:initial;margin-top:0;display:block}
|
|
849
|
+
.ba-box{position:absolute;border:1.5px solid var(--danger);border-radius:2px;pointer-events:none}
|
|
850
|
+
.ba-zoomhd{margin-top:14px}
|
|
851
|
+
.lg-ev .ba-zoomimg{display:block;width:100%;height:auto;max-height:none;object-fit:initial;margin-top:8px;border-radius:9px;border:1px solid var(--hair2)}
|
|
832
852
|
.reqbtns{display:flex;gap:8px;margin-top:4px}
|
|
833
853
|
.reqbtn{height:30px;padding:0 14px;border-radius:9px;border:1px solid var(--hair2);background:transparent;
|
|
834
854
|
color:var(--ink2);font:inherit;font-size:12.5px;font-weight:600;cursor:pointer;transition:.15s}
|
|
@@ -1526,6 +1546,20 @@
|
|
|
1526
1546
|
.fsbcol:hover .fsbcadd{opacity:.7}
|
|
1527
1547
|
.fsbcadd:hover{opacity:1;color:var(--ink);background:var(--hover)}
|
|
1528
1548
|
.fsbcadd svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round}
|
|
1549
|
+
/* Doing列の一時停止/再生トグル (Masa 2026-07-18・Spotify式). Running=hoverで出る一時停止
|
|
1550
|
+
❚❚ / paused=常時表示の再生 ▶ (停止状態が読める+再開できる). hoverは面を塗らずグリフ
|
|
1551
|
+
色のみ (DESIGN-RULES §5.5). 中立グレー: 緑/赤はstatusに読めるので使わない. board版=col
|
|
1552
|
+
ヘッダの Doing ラベル隣, list版=下の .tlcap-pause が #fsTodoSec の DOING 見出し隣. */
|
|
1553
|
+
.fsbcpause{width:20px;height:20px;display:grid;place-items:center;border:0;background:none;color:var(--ink3);cursor:pointer;border-radius:5px;opacity:0;transition:opacity .12s,color .12s}
|
|
1554
|
+
.fsbcol:hover .fsbcpause{opacity:.7}
|
|
1555
|
+
.fsbcpause:hover{opacity:1;color:var(--ink)}
|
|
1556
|
+
.fsbcpause[aria-pressed="true"]{opacity:1;color:var(--ink)}
|
|
1557
|
+
.fsbcpause svg{width:15px;height:15px;fill:currentColor;stroke:none}
|
|
1558
|
+
.tlcap-pause{display:inline-grid;place-items:center;width:15px;height:15px;vertical-align:-3px;margin-left:6px;border:0;background:none;color:var(--ink3);cursor:pointer;border-radius:4px;opacity:0;transition:opacity .12s,color .12s}
|
|
1559
|
+
#fsTodoSec:hover .tlcap-pause{opacity:.7}
|
|
1560
|
+
.tlcap-pause:hover{opacity:1;color:var(--ink)}
|
|
1561
|
+
.tlcap-pause[aria-pressed="true"]{opacity:1;color:var(--ink)}
|
|
1562
|
+
.tlcap-pause svg{width:11px;height:11px;fill:currentColor;stroke:none}
|
|
1529
1563
|
.fsbbody{flex:1;min-height:12px;overflow-y:auto;padding:1px 2px 6px;display:flex;flex-direction:column;gap:5px}
|
|
1530
1564
|
.fsbbody::-webkit-scrollbar{width:7px}.fsbbody::-webkit-scrollbar-thumb{background:var(--hair);border-radius:6px}
|
|
1531
1565
|
/* Board card = Depth (CDO, Masa 2026-07-17): a card is SCANNED, not read — #NN + a
|
|
@@ -1781,40 +1815,95 @@
|
|
|
1781
1815
|
.sa-ic{width:30px;height:30px;border:0;border-radius:8px;background:transparent;color:var(--ink3);display:grid;place-items:center;cursor:pointer;flex:0 0 30px}
|
|
1782
1816
|
.sa-ic:hover{color:var(--ink);background:transparent} /* icon hover = glyph colour only (§5.5) */
|
|
1783
1817
|
.sa-ic svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
|
|
1784
|
-
/* ──
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1818
|
+
/* ── Review card, evidence-first (H23 2026-07-18 full spec, superseding H22/H06):
|
|
1819
|
+
Evidence sits right under the title/meta row, Context is a closed-by-default
|
|
1820
|
+
<details> with a one-line quote summary, and Evidence itself branches by task
|
|
1821
|
+
kind (image/logic/docs/missing). Context / Done when / Evidence all sit on ONE
|
|
1822
|
+
flush surface now — hairlines only, no more Evidence-only canvas box (§5.5 "面の
|
|
1823
|
+
処理はパネル全体に均一"). Ported from docs/design/flagship/flagship.html
|
|
1824
|
+
(~L340-434), flagship --red/--green-bg substituted for the app's real tokens
|
|
1825
|
+
(--danger, rgba(var(--green-rgb),.x)). Old Plan/Review-thread/Changed-files&diff
|
|
1826
|
+
folds (.lg-det/.lg-plan/.lg-thread/.lg-diffwrap) are retired in favour of the
|
|
1827
|
+
What-we-did/What-to-look-at bullet lists inside Context. */
|
|
1828
|
+
.lg-hd{display:flex;align-items:baseline;gap:10px;margin-bottom:2px}
|
|
1789
1829
|
.lg-hd .no{flex:0 0 auto;font:600 11px/1 var(--mono);color:var(--ink3);font-variant-numeric:tabular-nums}
|
|
1790
1830
|
.lg-hd .t{flex:1;font:600 15px/1.3 var(--ui);letter-spacing:-.014em}
|
|
1791
1831
|
.lg-state{flex:0 0 auto;font:600 9px/1 var(--mono);letter-spacing:.11em;text-transform:uppercase} /* state = colour text only (§5.5 no badge) */
|
|
1792
1832
|
.lg-state.review{color:var(--blue)}
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
.lg-
|
|
1833
|
+
/* meta row: PR link / token savings (real tap-to-open toggle) / Run locally — a
|
|
1834
|
+
compact row directly under the title (H23 §1, pattern 1 won over a plain text
|
|
1835
|
+
line and title-row-right-aligned chips). */
|
|
1836
|
+
.lg-meta{display:flex;flex-wrap:wrap;align-items:center;gap:7px;margin:9px 0 13px}
|
|
1837
|
+
.lg-meta .none{font:500 10.5px/1.5 var(--mono);color:var(--ink3);font-style:italic}
|
|
1838
|
+
.sa-prlink.sm{font-size:9.5px;padding:4px 7px}
|
|
1839
|
+
.sa-runlocal{display:inline-flex;align-items:center;gap:5px;font:600 10px/1 var(--mono);letter-spacing:.03em;
|
|
1840
|
+
color:var(--blue);background:transparent;border:1px solid color-mix(in srgb,var(--blue) 35%,transparent);
|
|
1841
|
+
border-radius:7px;padding:5px 9px 5px 8px;cursor:pointer}
|
|
1842
|
+
.sa-runlocal:hover{border-color:var(--blue)}
|
|
1843
|
+
.sa-runlocal svg{width:11px;height:11px;fill:currentColor;stroke:none}
|
|
1844
|
+
.tokchip{display:inline-flex;align-items:center;gap:4px;font:600 9.5px/1 var(--mono);letter-spacing:.02em;color:var(--green);
|
|
1845
|
+
background:rgba(var(--green-rgb),.12);border:0;border-radius:7px;padding:5px 9px;box-shadow:inset 0 0 0 1px rgba(var(--green-rgb),.22);
|
|
1846
|
+
cursor:pointer}
|
|
1847
|
+
.tokchip:hover{box-shadow:inset 0 0 0 1px var(--green)}
|
|
1848
|
+
.tokchevron{font-style:normal;font-size:11px;line-height:1;transition:transform .2s;opacity:.75}
|
|
1849
|
+
.tokchip.open .tokchevron{transform:rotate(90deg)}
|
|
1850
|
+
.tokdetail{display:none;margin:0 0 13px;padding:9px 11px;background:var(--canvas);border-radius:8px}
|
|
1851
|
+
.tokdetail.open{display:block}
|
|
1852
|
+
.tokdetail .k{font:600 8px/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--ink3);margin-bottom:5px;display:block}
|
|
1853
|
+
.tokdetail .v{font:500 11px/1.6 var(--mono);color:var(--ink2)}
|
|
1854
|
+
/* Context: closed by default, one-line quote summary, chevron large + far right (H23 §2) */
|
|
1855
|
+
.lg-ctx{display:flex;flex-direction:column;gap:10px;margin-bottom:14px;font-size:12.5px;line-height:1.5}
|
|
1856
|
+
.lg-fact{display:grid;grid-template-columns:66px 1fr;gap:7px 12px}
|
|
1857
|
+
.lg-fact .k{font:600 8.5px/1.8 var(--mono);letter-spacing:.1em;color:var(--ink3);text-transform:uppercase}
|
|
1858
|
+
.lg-fact .v{color:var(--ink)}
|
|
1859
|
+
.lg-goal{border:0}
|
|
1860
|
+
.lg-goal>summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:6px;color:var(--ink3);min-width:0}
|
|
1861
|
+
.lg-goal>summary::-webkit-details-marker{display:none}
|
|
1862
|
+
.lg-goal>summary .k{font:600 8.5px/1.8 var(--mono);letter-spacing:.1em;color:var(--ink3);text-transform:uppercase;flex:0 0 auto}
|
|
1863
|
+
.lg-goal>summary .v{color:var(--ink);font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:0 1 auto}
|
|
1864
|
+
.lg-goal>summary .gtoggle{font-style:normal;font-size:19px;line-height:1;transition:transform .2s;flex:0 0 auto;
|
|
1865
|
+
margin-left:auto;color:var(--ink3);padding-left:10px}
|
|
1866
|
+
.lg-goal:hover>summary{color:var(--ink2)}
|
|
1867
|
+
.lg-goal:hover>summary .gtoggle{color:var(--ink)}
|
|
1868
|
+
.lg-goal[open]>summary .gtoggle{transform:rotate(90deg)}
|
|
1869
|
+
.lg-context{margin-top:10px;padding-top:10px;border-top:1px solid var(--hair)}
|
|
1870
|
+
/* fanned-out goal: list every sibling task/goal so a reviewer never wonders whether
|
|
1871
|
+
the AI silently dropped part of a long ask (H23 §2/§4). */
|
|
1872
|
+
.lg-splitnote{background:var(--canvas);border-radius:8px;padding:10px 12px;margin-bottom:13px}
|
|
1873
|
+
.lg-splitnote .hd{font:600 8.5px/1 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--ink3);margin-bottom:7px}
|
|
1874
|
+
.lg-splitnote ul{margin:0;padding:0;list-style:none;font-size:11.5px;line-height:1.8;color:var(--ink3)}
|
|
1875
|
+
.lg-splitnote li.cur{color:var(--ink)}
|
|
1876
|
+
.lg-splitnote li.cur b{color:var(--green);font-weight:600}
|
|
1877
|
+
.lg-notfound{font-size:11.5px;line-height:1.65;color:var(--ink3);font-style:italic;margin-bottom:13px}
|
|
1878
|
+
.lg-bullethd{font:600 8px/1 var(--mono);letter-spacing:.1em;color:var(--ink3);text-transform:uppercase;margin:13px 0 6px}
|
|
1879
|
+
.lg-bullets{margin:0;padding:0 0 0 16px;font-size:12px;line-height:1.65;color:var(--ink2)}
|
|
1880
|
+
.lg-bullets li{margin-bottom:5px}
|
|
1881
|
+
/* Evidence: same flush surface as Context/Done when — hairline only, no boxed canvas
|
|
1882
|
+
(H23 §5.5 correction: "コンテキストとダウンウェインとエビデンスが全部違うレイヤー") */
|
|
1883
|
+
.lg-ev{border-top:1px solid var(--hair);padding-top:13px;margin-top:2px}
|
|
1797
1884
|
.lg-ev .lg-evhd{font:600 8.5px/1 var(--mono);letter-spacing:.13em;color:var(--ink3);text-transform:uppercase;margin-bottom:10px}
|
|
1798
1885
|
.lg-ev .row{display:flex;flex-wrap:wrap;align-items:center;gap:8px 15px;font:500 11.5px/1.2 var(--mono);font-variant-numeric:tabular-nums}
|
|
1799
1886
|
.lg-ev .a{color:var(--green);font-style:normal} .lg-ev .d{color:var(--danger);font-style:normal}
|
|
1800
1887
|
.lg-ev .fls{color:var(--ink3)}
|
|
1801
1888
|
.lg-ev img{display:block;width:100%;max-height:190px;object-fit:cover;object-position:top;border-radius:9px;margin-top:11px;cursor:zoom-in}
|
|
1802
|
-
/*
|
|
1803
|
-
.lg-
|
|
1804
|
-
.lg-
|
|
1805
|
-
.lg-
|
|
1806
|
-
.lg-
|
|
1807
|
-
|
|
1808
|
-
.lg-
|
|
1809
|
-
|
|
1810
|
-
.lg-
|
|
1811
|
-
.lg-
|
|
1812
|
-
.lg-
|
|
1813
|
-
.lg-
|
|
1814
|
-
.lg-
|
|
1815
|
-
|
|
1816
|
-
.lg-
|
|
1817
|
-
|
|
1889
|
+
/* logic-only hero: no UI to show, so the number IS the evidence */
|
|
1890
|
+
.lg-herostat{display:flex;align-items:baseline;gap:10px;margin:4px 0 12px}
|
|
1891
|
+
.lg-herostat .n{font:700 30px/1 var(--mono);color:var(--green);font-variant-numeric:tabular-nums}
|
|
1892
|
+
.lg-herostat.fail .n{color:var(--danger)}
|
|
1893
|
+
.lg-herostat .l{font:500 11px/1.4 var(--ui);color:var(--ink3)}
|
|
1894
|
+
/* docs-only hero: GitHub-style diff — added lines get a green band + "+" gutter */
|
|
1895
|
+
.lg-diffblock{background:var(--canvas);border-radius:7px;padding:7px 0;font:400 11.5px/1.8 var(--mono);
|
|
1896
|
+
color:var(--ink2);margin-top:6px;overflow-x:auto}
|
|
1897
|
+
.lg-diffblock .ln{display:flex;padding:0 11px}
|
|
1898
|
+
.lg-diffblock .ln.a{background:rgba(var(--green-rgb),.14)}
|
|
1899
|
+
.lg-diffblock .gutter{flex:0 0 14px;color:var(--green);font-weight:700;user-select:none}
|
|
1900
|
+
.lg-diffblock .ln:not(.a) .gutter{color:transparent}
|
|
1901
|
+
.lg-diffblock .ln.a .txt{color:var(--green)}
|
|
1902
|
+
/* missing-capture hero: an honest small amber note, never a fabricated image */
|
|
1903
|
+
.lg-missinghd{display:flex;align-items:center;gap:8px;font:600 9px/1 var(--mono);letter-spacing:.1em;
|
|
1904
|
+
text-transform:uppercase;color:var(--amber);margin-bottom:9px}
|
|
1905
|
+
.lg-missinghd svg{width:14px;height:14px;flex:0 0 14px;fill:none;stroke:currentColor;stroke-width:2}
|
|
1906
|
+
.lg-manualnote{font-size:12px;line-height:1.7;color:var(--ink2);background:var(--canvas);border-radius:8px;padding:11px 13px}
|
|
1818
1907
|
/* risk/conflict = small inline amber, never a filled panel (§1.5-8 don't paint the surface) */
|
|
1819
1908
|
.lg-risk{display:flex;align-items:center;gap:8px;color:var(--amber);font-size:12px;line-height:1.4;padding:9px 2px;border-top:1px solid var(--hair)}
|
|
1820
1909
|
.lg-risk svg{width:14px;height:14px;flex:0 0 14px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
|
@@ -1847,6 +1936,7 @@
|
|
|
1847
1936
|
/* To Do timeline: order via tone + labels, no axis line (§1.5-4) */
|
|
1848
1937
|
#fsRoot .tl{position:relative}
|
|
1849
1938
|
#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) */
|
|
1939
|
+
#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. */
|
|
1850
1940
|
#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) */
|
|
1851
1941
|
#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 */
|
|
1852
1942
|
#fsRoot .trow:hover{background:var(--hover-s)}
|
|
@@ -3231,6 +3321,40 @@ function buildReviewDigest({ goals, tasks } = {}){
|
|
|
3231
3321
|
}
|
|
3232
3322
|
return rows;
|
|
3233
3323
|
}
|
|
3324
|
+
// Review title: mirrors the label convention used throughout (reviewSummary.changed
|
|
3325
|
+
// → plan[0] → text) — pulled into one place so the review card's own title and its
|
|
3326
|
+
// sibling-goal titles (H23 split note) stay consistent.
|
|
3327
|
+
function saReviewTitle(g){
|
|
3328
|
+
return String(g?.reviewSummary?.changed || g?.plan?.[0] || g?.text || '').replace(/\s+/g, ' ').trim().slice(0, 90);
|
|
3329
|
+
}
|
|
3330
|
+
// H23 (2026-07-18) evidence-kind detection. Mirrors engine/lib.mjs isUiChange() /
|
|
3331
|
+
// hasTestRelevantChanges() — kept in sync manually since the browser script isn't a
|
|
3332
|
+
// module. flagship's demo data carries an explicit `kind` field
|
|
3333
|
+
// ('image'|'logic'|'docs'|'missing'); real goals/tasks have no such field, so the
|
|
3334
|
+
// review card derives one from real signals it already has (a captured proof image,
|
|
3335
|
+
// whether changed files look like UI source, whether they're all docs-only). This
|
|
3336
|
+
// is a HEURISTIC, not a stored fact on the goal — flagged as a judgment call in the
|
|
3337
|
+
// H23 implementation PR for a human to correct if it misclassifies real data.
|
|
3338
|
+
const UI_TOUCH_RE_LOCAL = /^app\/|\.(?:html|css|tsx|jsx|vue|svelte)$/i;
|
|
3339
|
+
function isUiChangeLocal(files){ return (files ?? []).some((f) => UI_TOUCH_RE_LOCAL.test(String(f ?? ''))); }
|
|
3340
|
+
const DOC_ONLY_RE_LOCAL = /(^|\/)(README|START-HERE|CHANGELOG|LICENSE)(\.[\w.-]+)?$|(^|\/)docs\/.*\.(md|mdx|txt)$|\.mdx?$|\.txt$/i;
|
|
3341
|
+
function hasTestRelevantChangesLocal(files){
|
|
3342
|
+
const fs = (files ?? []).map((f) => String(f ?? '').trim()).filter(Boolean);
|
|
3343
|
+
if (!fs.length) return true;
|
|
3344
|
+
return fs.some((f) => !DOC_ONLY_RE_LOCAL.test(f));
|
|
3345
|
+
}
|
|
3346
|
+
// Priority: image (a real capture exists) > missing (UI touched, should have a
|
|
3347
|
+
// capture per PRD §4(C) but doesn't) > docs (every changed file is docs-only) >
|
|
3348
|
+
// logic (verified only by tests, no UI). Falls back to 'image' (today's generic
|
|
3349
|
+
// fact-row + no image) when no signal is conclusive — never claims a stronger kind
|
|
3350
|
+
// than the data actually supports.
|
|
3351
|
+
function saEvidenceKind(filePaths, hasProofImg, testResult){
|
|
3352
|
+
if (hasProofImg) return 'image';
|
|
3353
|
+
if (isUiChangeLocal(filePaths)) return 'missing';
|
|
3354
|
+
if (filePaths.length && !hasTestRelevantChangesLocal(filePaths)) return 'docs';
|
|
3355
|
+
if (testResult?.ran) return 'logic';
|
|
3356
|
+
return 'image';
|
|
3357
|
+
}
|
|
3234
3358
|
// Mirrors engine/lib.mjs buildReviewSummary()/requirementSummaryLine() (task 96)
|
|
3235
3359
|
// — kept in sync manually since the browser script isn't a module. Used by both
|
|
3236
3360
|
// the review checklist top summary and the chat-side review导线.
|
|
@@ -3435,6 +3559,92 @@ function beforeAfterMediaHtml(taskId, proof, altLabel){
|
|
|
3435
3559
|
<div class="ba-col"><span class="balabel">After</span>${proofMediaHtml(taskId, proof, altLabel)}</div>
|
|
3436
3560
|
</div>`;
|
|
3437
3561
|
}
|
|
3562
|
+
// Diff box + zoomed inset (HANDOFF-review-card-diffbox-2026-07-18): on top of the
|
|
3563
|
+
// plain before/after pair above, isolate the region that actually changed and draw
|
|
3564
|
+
// a red box over it on both images, plus a cropped/enlarged row below. Computed
|
|
3565
|
+
// client-side from the two PNGs via <canvas> pixel comparison at render time — not
|
|
3566
|
+
// baked in at capture time — so it works retroactively on every proof pair already
|
|
3567
|
+
// on disk with no new server-side image dependency (see the handoff for the full
|
|
3568
|
+
// rationale). Reference algorithm, verified against a real before/after pair
|
|
3569
|
+
// (composer icon swap, 640x200): correctly isolated a 57x52px box centered on the
|
|
3570
|
+
// changed icon — 0.13% image coverage, no false positives elsewhere in the frame.
|
|
3571
|
+
function computeDiffBox(beforeImg, afterImg, { threshold = 24, minCoverage = 0.0005, maxCoverage = 0.7, pad = 0.03 } = {}) {
|
|
3572
|
+
const w = beforeImg.naturalWidth, h = beforeImg.naturalHeight;
|
|
3573
|
+
if (w !== afterImg.naturalWidth || h !== afterImg.naturalHeight) return null; // different-shaped images: don't guess
|
|
3574
|
+
const c1 = document.createElement('canvas'); c1.width = w; c1.height = h;
|
|
3575
|
+
const c2 = document.createElement('canvas'); c2.width = w; c2.height = h;
|
|
3576
|
+
c1.getContext('2d').drawImage(beforeImg, 0, 0, w, h);
|
|
3577
|
+
c2.getContext('2d').drawImage(afterImg, 0, 0, w, h);
|
|
3578
|
+
const d1 = c1.getContext('2d').getImageData(0, 0, w, h).data;
|
|
3579
|
+
const d2 = c2.getContext('2d').getImageData(0, 0, w, h).data;
|
|
3580
|
+
let minX = w, minY = h, maxX = -1, maxY = -1, diffPx = 0;
|
|
3581
|
+
for (let y = 0; y < h; y++) {
|
|
3582
|
+
for (let x = 0; x < w; x++) {
|
|
3583
|
+
const i = (y * w + x) * 4;
|
|
3584
|
+
const d = Math.abs(d1[i] - d2[i]) + Math.abs(d1[i + 1] - d2[i + 1]) + Math.abs(d1[i + 2] - d2[i + 2]);
|
|
3585
|
+
if (d > threshold) {
|
|
3586
|
+
diffPx++;
|
|
3587
|
+
if (x < minX) minX = x; if (x > maxX) maxX = x;
|
|
3588
|
+
if (y < minY) minY = y; if (y > maxY) maxY = y;
|
|
3589
|
+
}
|
|
3590
|
+
}
|
|
3591
|
+
}
|
|
3592
|
+
const total = w * h;
|
|
3593
|
+
if (maxX < 0 || diffPx / total < minCoverage) return null; // no real difference — don't box a no-op
|
|
3594
|
+
if (diffPx / total > maxCoverage) return null; // diff too diffuse to be "a spot" — a full redesign shouldn't get a red box either
|
|
3595
|
+
const padPx = Math.round(Math.max(w, h) * pad);
|
|
3596
|
+
minX = Math.max(0, minX - padPx); minY = Math.max(0, minY - padPx);
|
|
3597
|
+
maxX = Math.min(w, maxX + padPx); maxY = Math.min(h, maxY + padPx);
|
|
3598
|
+
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
|
3599
|
+
}
|
|
3600
|
+
function baLoadImage(src){
|
|
3601
|
+
return new Promise((resolve, reject) => {
|
|
3602
|
+
const im = new Image();
|
|
3603
|
+
im.onload = () => resolve(im);
|
|
3604
|
+
im.onerror = () => reject(new Error(`image failed to load: ${src}`));
|
|
3605
|
+
im.src = src;
|
|
3606
|
+
});
|
|
3607
|
+
}
|
|
3608
|
+
// Crop {x,y,w,h} out of a decoded image and scale it up into a small inset — same
|
|
3609
|
+
// aspect ratio as the box, never stretched. Magnification is capped at 6x so a
|
|
3610
|
+
// near-single-pixel box doesn't blow up into mush.
|
|
3611
|
+
function baZoomDataUrl(img, box, maxW = 420, maxH = 200){
|
|
3612
|
+
const scale = Math.min(maxW / box.w, maxH / box.h, 6);
|
|
3613
|
+
const outW = Math.max(1, Math.round(box.w * scale));
|
|
3614
|
+
const outH = Math.max(1, Math.round(box.h * scale));
|
|
3615
|
+
const c = document.createElement('canvas'); c.width = outW; c.height = outH;
|
|
3616
|
+
c.getContext('2d').drawImage(img, box.x, box.y, box.w, box.h, 0, 0, outW, outH);
|
|
3617
|
+
return c.toDataURL('image/png');
|
|
3618
|
+
}
|
|
3619
|
+
// Async: loads the before/after PNGs, runs computeDiffBox(), and — only when a real
|
|
3620
|
+
// box is found — renders the before/after pair with a red box overlay on each image
|
|
3621
|
+
// (percent-positioned so it tracks the image's displayed size at any viewport width)
|
|
3622
|
+
// plus a "Zoomed on the boxed area" row below. Returns null on every fallback
|
|
3623
|
+
// condition (no PNG pair, load failure, no clean box) so the caller keeps showing
|
|
3624
|
+
// the plain beforeAfterMediaHtml() pair unchanged — never a guessed or empty box
|
|
3625
|
+
// (HANDOFF-review-card-diffbox-2026-07-18). Only kind==='image' Ledger evidence
|
|
3626
|
+
// (saEvidenceHtml) calls this; the older per-task renderReviewChecklist before/after
|
|
3627
|
+
// is explicitly out of scope for this handoff and stays on beforeAfterMediaHtml.
|
|
3628
|
+
async function beforeAfterBoxedHtml(taskId, proof, altLabel){
|
|
3629
|
+
const beforeSrc = proof?.beforePng, afterSrc = proof?.png;
|
|
3630
|
+
if (!beforeSrc || !afterSrc) return null; // the box needs two real stills, not a GIF frame
|
|
3631
|
+
let beforeImg, afterImg;
|
|
3632
|
+
try {
|
|
3633
|
+
[beforeImg, afterImg] = await Promise.all([
|
|
3634
|
+
baLoadImage(withKey(`/proof/${taskId}/${beforeSrc}`)),
|
|
3635
|
+
baLoadImage(withKey(`/proof/${taskId}/${afterSrc}`)),
|
|
3636
|
+
]);
|
|
3637
|
+
} catch { return null; } // a missing/broken file is a fallback, not a crash
|
|
3638
|
+
const box = computeDiffBox(beforeImg, afterImg);
|
|
3639
|
+
if (!box) return null;
|
|
3640
|
+
const w = beforeImg.naturalWidth, h = beforeImg.naturalHeight;
|
|
3641
|
+
const boxStyle = `left:${(box.x / w * 100).toFixed(3)}%;top:${(box.y / h * 100).toFixed(3)}%;width:${(box.w / w * 100).toFixed(3)}%;height:${(box.h / h * 100).toFixed(3)}%`;
|
|
3642
|
+
const frame = (img, label) => `<div class="ba-col"><span class="balabel">${label}</span><div class="ba-imgwrap"><img class="proofimg" src="${img.src}" alt="${label.toLowerCase()}: ${altLabel}" loading="lazy"><div class="ba-box" style="${boxStyle}"></div></div></div>`;
|
|
3643
|
+
const zoomCol = (dataUrl, label) => `<div class="ba-col"><img class="ba-zoomimg" src="${dataUrl}" alt="zoomed ${label.toLowerCase()}: ${altLabel}"></div>`;
|
|
3644
|
+
return `<div class="beforeafter">${frame(beforeImg, 'Before')}${frame(afterImg, 'After')}</div>
|
|
3645
|
+
<div class="lg-evhd ba-zoomhd">Zoomed on the boxed area</div>
|
|
3646
|
+
<div class="beforeafter ba-zoomrow">${zoomCol(baZoomDataUrl(beforeImg, box), 'before')}${zoomCol(baZoomDataUrl(afterImg, box), 'after')}</div>`;
|
|
3647
|
+
}
|
|
3438
3648
|
// swap the still for the real video only on demand, one at a time
|
|
3439
3649
|
document.addEventListener('click', (e) => {
|
|
3440
3650
|
const w = e.target.closest('.proofwrap');
|
|
@@ -5554,19 +5764,28 @@ function renderFsLane(){
|
|
|
5554
5764
|
renderFsReview(); renderFsTodo();
|
|
5555
5765
|
if (fsUI.todoView === 'board') renderFsBoard();
|
|
5556
5766
|
}
|
|
5557
|
-
// Which board column a GOAL belongs in. Follows goalChip()'s rule so the board and
|
|
5558
|
-
//
|
|
5559
|
-
//
|
|
5560
|
-
|
|
5767
|
+
// Which board column a GOAL belongs in. Follows goalChip()'s rule so the board and the
|
|
5768
|
+
// status chips can never drift; the two columns the chips have no word for (Later / Needs
|
|
5769
|
+
// you) are spelled out here. Takes the goal's TASKS so it can tell a genuinely in-flight
|
|
5770
|
+
// goal from one whose status got stuck 'running' after every task already finished — that
|
|
5771
|
+
// stale case is what piled old goals into Doing (Masa 2026-07-19). Pure: goal + tasks in.
|
|
5772
|
+
function fsBoardBucket(goal, tasks){
|
|
5561
5773
|
const s = goal?.status;
|
|
5562
5774
|
if (s === 'pending') return 'later'; // shelved by the composer's "Later"
|
|
5563
5775
|
if (s === 'needsInput') return 'needs'; // a clarifying question is waiting on the user
|
|
5776
|
+
if (s === 'blocked' && goal?.blocked?.kind === 'paused') return 'doing'; // paused by user — stays in Doing, shown amber "Paused" (Masa 2026-07-19: 1カードのまま黄色に・Needs youへ移動しない)
|
|
5564
5777
|
if (['blocked', 'failed', 'partial', 'interrupted'].includes(s)) return 'needs'; // stopped/errored — needs YOUR action (retry), like the flagship board which puts .drow.err in Needs you. Not "Doing" — Doing is only what's actively moving (Masa 2026-07-18)
|
|
5565
5778
|
if (s === 'review') return 'review';
|
|
5566
|
-
if (s === 'done' || s === 'reverted') return 'done';
|
|
5779
|
+
if (s === 'done' || s === 'reverted' || s === 'skipped') return 'done'; // terminal; skipped is finished, not in flight (it used to fall through to the doing default)
|
|
5567
5780
|
if (s === 'stacked' || s === 'sending') return 'todo';
|
|
5568
|
-
if (s === 'running' && !goal
|
|
5569
|
-
|
|
5781
|
+
if (s === 'running' && !goal?.startedAt) return 'todo'; // goalChip: planned, nothing started yet
|
|
5782
|
+
// Otherwise the goal is in flight (planning / retesting / running-and-started). BUT a
|
|
5783
|
+
// goal whose status stuck 'running' after every task already finished is NOT moving —
|
|
5784
|
+
// that is exactly what piled stale goals into Doing (Masa 2026-07-19, "Doing に11件").
|
|
5785
|
+
// If it has tasks and they're all terminal, it belongs in Done, not Doing.
|
|
5786
|
+
const mine = (tasks ?? []).filter((t) => t.goalId === goal?.id && !t.reply);
|
|
5787
|
+
if (mine.length && mine.every((t) => ['done', 'skipped'].includes(t.status))) return 'done';
|
|
5788
|
+
return 'doing'; // planning / retesting / actively in flight
|
|
5570
5789
|
}
|
|
5571
5790
|
// Kanban board — GOAL-level: one card per goal, never per task.
|
|
5572
5791
|
//
|
|
@@ -5584,6 +5803,7 @@ function fsBoardBucket(goal){
|
|
|
5584
5803
|
// above (proof thumbnails, PR links, approve/dismiss) — the board doesn't duplicate it.
|
|
5585
5804
|
function renderFsBoard(){
|
|
5586
5805
|
const el = $('fsBoard'); if (!el) return;
|
|
5806
|
+
const paused = !!state.projects.find((p) => p.id === state.active)?.paused; // Doing列トグルの状態
|
|
5587
5807
|
const { pgoals, list } = fsBucketTodo();
|
|
5588
5808
|
const tt = (s, n) => esc(String(s ?? '').replace(/\s+/g, ' ').slice(0, n ?? 60));
|
|
5589
5809
|
const goalNo = new Map();
|
|
@@ -5594,21 +5814,23 @@ function renderFsBoard(){
|
|
|
5594
5814
|
// flagship mock; a new goal enters via the composer). Earlier this was To-Do-only, which read
|
|
5595
5815
|
// as "position is off" against the Artifact (Masa 2026-07-18).
|
|
5596
5816
|
const col = (name, stg, count, html, stage) =>
|
|
5597
|
-
`<section class="fsbcol" data-stage="${stage}" style="--stg:${stg}"><div class="fsbcolhd"><span class="fsbdot"></span><span class="fsbcolnm">${name}</span><span class="fsbcnt">${count}</span><button class="fsbcadd" data-fsnew="1" title="New goal"><svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg></button></div><div class="fsbbody">${html || '<div class="fsbempty">—</div>'}</div><button class="fsbadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button></section>`;
|
|
5817
|
+
`<section class="fsbcol" data-stage="${stage}" style="--stg:${stg}"><div class="fsbcolhd"><span class="fsbdot"></span><span class="fsbcolnm">${name}</span>${stage === 'doing' ? `<button class="fsbcpause" data-fspause="1" aria-pressed="${paused ? 'true' : 'false'}" title="${paused ? 'Resume — 再開' : 'Pause — 一時停止'}">${paused ? '<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>' : '<svg viewBox="0 0 24 24"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg>'}</button>` : ''}<span class="fsbcnt">${count}</span><button class="fsbcadd" data-fsnew="1" title="New goal"><svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg></button></div><div class="fsbbody">${html || '<div class="fsbempty">—</div>'}</div><button class="fsbadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button></section>`;
|
|
5598
5818
|
// Depth card (CDO, Masa 2026-07-17): #NN + title, colour on the dot only, and a continuous
|
|
5599
5819
|
// stroke + n/m ONLY while the goal is actually in flight. The column already names the
|
|
5600
5820
|
// status, so the card never repeats it. Stopped/failed carry NO bar (a dead bar would claim
|
|
5601
5821
|
// the goal is moving) — a coloured word (Failed/Interrupted) says it instead.
|
|
5602
5822
|
const goalTasks = (g) => list.filter((t) => t.goalId === g.id && !t.reply);
|
|
5603
5823
|
const goalCard = (g) => {
|
|
5604
|
-
const bkt = fsBoardBucket(g);
|
|
5824
|
+
const bkt = fsBoardBucket(g, list);
|
|
5605
5825
|
// errored is status-based now (blocked/failed/partial/interrupted live in the Needs-you column,
|
|
5606
5826
|
// not Doing). Doing holds only what's actively moving, so bkt==='doing' ⇒ healthy in flight.
|
|
5607
5827
|
const errored = ['blocked', 'failed', 'partial', 'interrupted'].includes(g.status);
|
|
5608
5828
|
const intr = g.status === 'interrupted';
|
|
5609
|
-
const
|
|
5829
|
+
const isPaused = g.status === 'blocked' && g.blocked?.kind === 'paused'; // 一時停止=エラーでなくアンバー扱い
|
|
5830
|
+
const amber = intr || isPaused;
|
|
5831
|
+
const healthyRun = bkt === 'doing' && !errored; // paused(=errored) goals now sit in 'doing' too but carry no progress bar
|
|
5610
5832
|
let dot = '', word = '';
|
|
5611
|
-
if (errored) { dot =
|
|
5833
|
+
if (errored) { dot = amber ? 'intr' : 'bad'; word = isPaused ? 'Paused' : intr ? 'Interrupted' : 'Failed'; } // shown in Needs you, with the word
|
|
5612
5834
|
else if (bkt === 'review') dot = 'rev';
|
|
5613
5835
|
else if (bkt === 'done') dot = 'ok';
|
|
5614
5836
|
else if (bkt === 'needs') dot = 'intr'; // your turn — a clarifying question is waiting
|
|
@@ -5619,7 +5841,7 @@ function renderFsBoard(){
|
|
|
5619
5841
|
let h = '<div class="lbc-hd">'
|
|
5620
5842
|
+ (dot ? `<span class="lbc-dot ${dot}"></span>` : '')
|
|
5621
5843
|
+ (n != null ? `<span class="lbc-n">#${n}</span>` : '')
|
|
5622
|
-
+ (word ? `<span class="lbc-w ${
|
|
5844
|
+
+ (word ? `<span class="lbc-w ${amber ? 'intr' : 'bad'}">${word}</span>` : '')
|
|
5623
5845
|
+ '</div>'
|
|
5624
5846
|
+ `<div class="lbc-t">${tt(g.text || g.plan?.[0], 90)}</div>`;
|
|
5625
5847
|
// n/m is Doing-only (Masa 2026-07-17): a fraction on a stopped/queued/done card is
|
|
@@ -5636,7 +5858,7 @@ function renderFsBoard(){
|
|
|
5636
5858
|
};
|
|
5637
5859
|
const no = (g) => goalNo.get(g.id) ?? Infinity;
|
|
5638
5860
|
const bucket = { todo: [], doing: [], needs: [], review: [], done: [], later: [] };
|
|
5639
|
-
for (const g of pgoals) bucket[fsBoardBucket(g)].push(g);
|
|
5861
|
+
for (const g of pgoals) bucket[fsBoardBucket(g, list)].push(g);
|
|
5640
5862
|
for (const k of Object.keys(bucket)) bucket[k].sort((a, b) => k === 'done' ? no(b) - no(a) : no(a) - no(b));
|
|
5641
5863
|
// To Do / Doing / Needs you / Review / Done / Later — the original design's columns
|
|
5642
5864
|
// (Masa 2026-07-16). "Needs you" = a clarifying question is waiting on the user —
|
|
@@ -5650,8 +5872,22 @@ function renderFsBoard(){
|
|
|
5650
5872
|
+ col('Done', 'var(--ink3)', bucket.done.length, bucket.done.slice(0, 40).map(goalCard).join(''), 'done')
|
|
5651
5873
|
+ col('Later', 'var(--ink3)', bucket.later.length, bucket.later.map(goalCard).join(''), 'later');
|
|
5652
5874
|
for (const b of el.querySelectorAll('[data-fsnew]')) b.onclick = () => $('input')?.focus(); // every + New / header + focuses the composer
|
|
5875
|
+
for (const b of el.querySelectorAll('[data-fspause]')) b.onclick = togglePauseActive; // Doing列の一時停止/再生
|
|
5653
5876
|
renderFsWait(bucket);
|
|
5654
5877
|
}
|
|
5878
|
+
// Doing列の一時停止/再生 (Masa 2026-07-18・Spotify式トグル). 楽観的にローカルの
|
|
5879
|
+
// project.paused を反転して即描画→POST. サーバは pause/resume 後に {ev:'projects'}
|
|
5880
|
+
// を broadcast するので、確定状態はSSEハンドラが state.projects を上書きして一致
|
|
5881
|
+
// させる. POST失敗時だけ元に戻す. board/list 両方の [data-fspause] が共有する。
|
|
5882
|
+
function togglePauseActive(){
|
|
5883
|
+
const p = state.projects.find((x) => x.id === state.active);
|
|
5884
|
+
if (!p) return;
|
|
5885
|
+
const next = !p.paused;
|
|
5886
|
+
p.paused = next; render();
|
|
5887
|
+
fetch(withKey(`/api/projects/${state.active}/${next ? 'pause' : 'resume'}`), { method: 'POST', headers: { 'content-type': 'application/json' } })
|
|
5888
|
+
.then((r) => { if (!r.ok) throw new Error(`pause toggle ${r.status}`); })
|
|
5889
|
+
.catch(() => { p.paused = !next; render(); });
|
|
5890
|
+
}
|
|
5655
5891
|
// Waiting badge (CDO, Masa 2026-07-17 decision 2): the board keeps work-flow column order, so
|
|
5656
5892
|
// the "your turn" columns (Needs you, Review) can sit off the right edge at a narrow lane
|
|
5657
5893
|
// width. They announce themselves beside the List/Board toggle instead — pure read of the same
|
|
@@ -5840,7 +6076,11 @@ function fsBucketTodo(){
|
|
|
5840
6076
|
// error as a small in-row colour element (§1.5-8), quiet Done fold.
|
|
5841
6077
|
function renderFsTodo(){
|
|
5842
6078
|
const el = $('fsTodoSec'); if (!el) return;
|
|
5843
|
-
const { pgoals, list, running, failed, blocked, queued, doneAll, later } = fsBucketTodo();
|
|
6079
|
+
const { pgoals, list, running, failed, blocked, needsInput, queued, doneAll, later } = fsBucketTodo();
|
|
6080
|
+
const paused = !!state.projects.find((p) => p.id === state.active)?.paused; // list版 Doing トグルの状態
|
|
6081
|
+
// 一時停止したゴール(blocked kind:'paused')は Doing に「Paused」1行で留める(Masa 2026-07-19)。
|
|
6082
|
+
// Needs you 側(failed/blocked)へは出さず、その走行/中断タスク行も抑止して二重行を作らない。
|
|
6083
|
+
const pausedGoalIds = new Set(pgoals.filter((g) => g.status === 'blocked' && g.blocked?.kind === 'paused').map((g) => g.id));
|
|
5844
6084
|
const tt = (s, n) => esc(String(s ?? '').replace(/\s+/g, ' ').slice(0, n ?? 70));
|
|
5845
6085
|
// LATER = deliberately shelved goals (composer "Later" send → server status 'pending';
|
|
5846
6086
|
// PRD §8.2 top gap — they were invisible in flagship). Staged disclosure: the cap
|
|
@@ -5855,16 +6095,31 @@ function renderFsTodo(){
|
|
|
5855
6095
|
const goalNo = new Map();
|
|
5856
6096
|
for (const g of pgoals) { const n = goalReviewNumber({ goal: g, tasks: list }); if (n != null) goalNo.set(g.id, n); }
|
|
5857
6097
|
const noSpan = (goalId) => { const n = goalId != null ? goalNo.get(goalId) : null; return n != null ? `<span class="gno" data-n="${n}">#${n}</span>` : ''; };
|
|
6098
|
+
// DOING = only what is actively moving (running task rows). Stopped/errored work moved
|
|
6099
|
+
// out to its own "Needs you" caption below (Masa 2026-07-19) — folding it into Doing read
|
|
6100
|
+
// as "still in flight".
|
|
5858
6101
|
const doingRows =
|
|
5859
|
-
running.map((t) => `<div class="drow"><div class="trow now"><span class="st run"></span>${noSpan(t.goalId)}<span class="tt">${tt(t.title)}</span><span class="pct num">${progressFor(t)}%</span>${fsActs(t.goalId)}</div>
|
|
6102
|
+
running.filter((t) => !pausedGoalIds.has(t.goalId)).map((t) => `<div class="drow"><div class="trow now"><span class="st run"></span>${noSpan(t.goalId)}<span class="tt">${tt(t.title)}</span><span class="pct num">${progressFor(t)}%</span>${fsActs(t.goalId)}</div>
|
|
5860
6103
|
<div class="tbar"><i style="width:${progressFor(t)}%"></i></div></div>`).join('')
|
|
6104
|
+
// 一時停止したゴールは Doing に「Paused」1行(アンバー)で留める+Resume。走行/中断行は上と needsRows で抑止済み。
|
|
6105
|
+
+ pgoals.filter((g) => pausedGoalIds.has(g.id)).map((g) => `<div class="drow"><div class="trow now"><span class="st run int"></span>${noSpan(g.id)}<span class="tt">${tt(g.reviewSummary?.check || g.plan?.[0] || g.text, 60)}</span><span class="stword int">Paused</span><button class="ab txt" data-fspause="1" title="Resume — 再開">Resume</button></div></div>`).join('');
|
|
6106
|
+
// NEEDS YOU: stopped/errored work that used to fold into Doing, now under its own caption so
|
|
6107
|
+
// it no longer reads as "still in flight" (Masa 2026-07-19). Three kinds share it, each
|
|
6108
|
+
// keeping the affordances it already had — nothing about the failed-task and blocked-goal
|
|
6109
|
+
// rows changed except the caption they sit under:
|
|
6110
|
+
// • a failed/interrupted TASK — the rich Retry/Resume row;
|
|
6111
|
+
// • a BLOCKED goal — the Retry/Archive goal row (a blocked goal can ALSO have a stopped
|
|
6112
|
+
// task; both rows show, as before — they carry different, goal- vs task-level actions);
|
|
6113
|
+
// • a needsInput goal — new here: a clarifying question waiting on you (Answer opens it,
|
|
6114
|
+
// where the chat feed's question card takes the actual answer).
|
|
6115
|
+
const needsRows =
|
|
5861
6116
|
// Doing-failure UI (HANDOFF-v45 §11, revises v45 §6): a stopped task shows NO progress
|
|
5862
6117
|
// bar (a dead bar pushed Retry down) — state is a COLOURED TEXT WORD only, no badge.
|
|
5863
6118
|
// Interrupted (amber, ⏸, "Resume") is distinguished from Failed (red, ✗, "Retry") —
|
|
5864
6119
|
// both still call the same /retry endpoint, which already resumes the session when one
|
|
5865
6120
|
// exists (server-side retry = "resume if possible, else re-run"; there is no separate
|
|
5866
6121
|
// resume endpoint to call).
|
|
5867
|
-
|
|
6122
|
+
failed.filter((t) => !pausedGoalIds.has(t.goalId)).map((t) => { const isInt = t.status === 'interrupted';
|
|
5868
6123
|
const word = isInt ? 'Interrupted' : 'Failed', icon = isInt ? '⏸' : '✗', label = isInt ? 'Resume' : 'Retry';
|
|
5869
6124
|
// don't restate the state word as the "reason" too (e.g. "Interrupted / Interrupted")
|
|
5870
6125
|
// when the task has no distinct result text — the coloured word already said it, so
|
|
@@ -5875,9 +6130,12 @@ function renderFsTodo(){
|
|
|
5875
6130
|
return `<div class="drow err${isInt ? ' int' : ''}"><div class="trow now"><span class="st run ${isInt ? 'int' : 'bad'}"></span>${noSpan(t.goalId)}<span class="tt">${tt(t.title)}</span><span class="stword ${isInt ? 'int' : ''}">${word}</span>${fsActs(t.goalId)}</div>
|
|
5876
6131
|
<div class="errline">${reasonLine}
|
|
5877
6132
|
<div class="acts2"><button class="errbtn" data-fsretry="${t.id}">${label}</button><button class="errbtn" data-fslog="${t.id}">View log</button></div></div></div>`; }).join('')
|
|
5878
|
-
+ blocked.map((g) => `<div class="drow err"><div class="trow now"><span class="st run bad"></span>${noSpan(g.id)}<span class="tt">${tt(g.reviewSummary?.check || g.plan?.[0] || g.text, 60)}</span><span class="stword">Failed</span></div>
|
|
6133
|
+
+ blocked.map((g) => g.blocked?.kind === 'paused' ? '' : `<div class="drow err"><div class="trow now"><span class="st run bad"></span>${noSpan(g.id)}<span class="tt">${tt(g.reviewSummary?.check || g.plan?.[0] || g.text, 60)}</span><span class="stword">Failed</span></div>
|
|
5879
6134
|
<div class="errline"><div class="msg"><span class="x">✗</span>${tt(g.blocked?.reason ?? 'blocked', 180)}</div>
|
|
5880
|
-
<div class="acts2"><button class="errbtn" data-fsreverify="${g.id}">Retry</button><button class="errbtn" data-fsglog="${g.id}">View log</button><button class="errbtn" data-fsarchive="${g.id}">Archive</button></div></div></div>`).join('')
|
|
6135
|
+
<div class="acts2"><button class="errbtn" data-fsreverify="${g.id}">Retry</button><button class="errbtn" data-fsglog="${g.id}">View log</button><button class="errbtn" data-fsarchive="${g.id}">Archive</button></div></div></div>`).join('')
|
|
6136
|
+
+ needsInput.map((g) => `<div class="drow err int"><div class="trow now"><span class="st run int"></span>${noSpan(g.id)}<span class="tt">${tt(g.text || g.plan?.[0], 60)}</span><span class="stword int">Needs input</span></div>
|
|
6137
|
+
<div class="errline"><div class="msg"><span class="x int">?</span>${tt(g.question?.text || 'A question is waiting on you', 180)}</div>
|
|
6138
|
+
<div class="acts2"><button class="errbtn" data-fsglog="${g.id}">Answer</button></div></div></div>`).join('');
|
|
5881
6139
|
const NCAP = 6;
|
|
5882
6140
|
const upShown = fsUI.todoMore ? queued : queued.slice(0, NCAP);
|
|
5883
6141
|
const seenGoals = new Set();
|
|
@@ -5898,11 +6156,12 @@ function renderFsTodo(){
|
|
|
5898
6156
|
// "Next up" pool only) — so a project sitting in Doing/Later with an empty Next-up
|
|
5899
6157
|
// queue showed "To Do 0" directly above visible Doing/Later rows. The header labels
|
|
5900
6158
|
// the whole lane ("To Do"), so it now counts everything the lane actually renders
|
|
5901
|
-
// below it: Doing (running + failed/interrupted
|
|
5902
|
-
// (queued) + Later (pending goals). "· next-up order" stays keyed off
|
|
5903
|
-
// specifically, since that suffix describes the Next-up sublist's own sort,
|
|
5904
|
-
// lane total.
|
|
5905
|
-
const
|
|
6159
|
+
// below it: Doing (running) + Needs you (failed/interrupted tasks + stopped/needsInput
|
|
6160
|
+
// goals) + Next up (queued) + Later (pending goals). "· next-up order" stays keyed off
|
|
6161
|
+
// queued.length specifically, since that suffix describes the Next-up sublist's own sort,
|
|
6162
|
+
// not the lane total.
|
|
6163
|
+
const needsCount = failed.length + blocked.length + needsInput.length;
|
|
6164
|
+
const todoTotal = running.length + needsCount + queued.length + later.length;
|
|
5906
6165
|
// Free-tier pending counter (H22): on the free plan the header shows the wall
|
|
5907
6166
|
// — pending goals out of the 5-slot cap — reddening at the cap and gone once
|
|
5908
6167
|
// paid. Reads the SAME numbers the gate enforces (state.billing, never a local
|
|
@@ -5916,8 +6175,9 @@ function renderFsTodo(){
|
|
|
5916
6175
|
: `<span class="num fstdlabel">${todoTotal}${queued.length ? ' · next-up order' : ''}</span>`;
|
|
5917
6176
|
el.innerHTML = `<div class="lhd sechd" id="fsTodoHd"><span class="hd fstdlabel">To Do</span>${_todoNum}<span class="fold fstdlabel">▾</span></div>
|
|
5918
6177
|
<div class="tl">
|
|
5919
|
-
${doingRows ? `<span class="tlcap" style="padding-top:0">Doing</span>${doingRows}` : ''}
|
|
5920
|
-
${
|
|
6178
|
+
${(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
|
+
${needsRows ? `<span class="tlcap needs"${(doingRows || paused) ? '' : ' style="padding-top:0"'}>Needs you</span>${needsRows}` : ''}
|
|
6180
|
+
${upRows ? `<span class="tlcap"${(doingRows || paused) || needsRows ? '' : ' style="padding-top:0"'}>Next up</span>${upRows}` : ''}
|
|
5921
6181
|
${queued.length > NCAP ? `<div class="morefold" id="fsTodoMore">${fsUI.todoMore ? 'less' : `+${queued.length - NCAP} more`}<span class="g">›</span></div>` : ''}
|
|
5922
6182
|
${laterRows ? `<span class="tlcap">Later</span>${laterRows}` : ''}
|
|
5923
6183
|
<button class="qadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button>
|
|
@@ -5937,6 +6197,7 @@ function renderFsTodo(){
|
|
|
5937
6197
|
fsUI.repsOpen.has(id) ? fsUI.repsOpen.delete(id) : fsUI.repsOpen.add(id);
|
|
5938
6198
|
b.closest('.grp').classList.toggle('open');
|
|
5939
6199
|
};
|
|
6200
|
+
for (const b of el.querySelectorAll('[data-fspause]')) b.onclick = togglePauseActive; // list版 Doing 一時停止/再生(+Paused行のResume)
|
|
5940
6201
|
for (const b of el.querySelectorAll('[data-fsretry]')) b.onclick = () => fsRetryTask(b.dataset.fsretry);
|
|
5941
6202
|
for (const b of el.querySelectorAll('[data-fslog]')) b.onclick = () => fsToggleLog(Number(b.dataset.fslog));
|
|
5942
6203
|
for (const b of el.querySelectorAll('[data-fsglog]')) b.onclick = () => toggleGoalDetail(Number(b.dataset.fsglog));
|
|
@@ -7411,7 +7672,7 @@ function closeSummaryPera(){ $('summaryOverlay').classList.remove('show'); }
|
|
|
7411
7672
|
// 4 archived / 5 reverted — session-local; the server round-trip is what
|
|
7412
7673
|
// settles it (see saSync's "returned to review" reset).
|
|
7413
7674
|
// ============================================================================
|
|
7414
|
-
const SA = { open: false, items: [], diffs: {} , detOpen: new Set(), focusGid: null, focusScrolled: false, focusTimer: null, cur: null };
|
|
7675
|
+
const SA = { open: false, items: [], diffs: {}, diffBoxHtml: {}, detOpen: new Set(), focusGid: null, focusScrolled: false, focusTimer: null, cur: null };
|
|
7415
7676
|
window.SA = SA; // top-level const isn't a window property; external checks probe window.SA (same convention as window.state above)
|
|
7416
7677
|
const SA_SVG = {
|
|
7417
7678
|
ok: '<svg viewBox="0 0 24 24"><path d="M4 12l5 5L20 6"/></svg>',
|
|
@@ -7424,27 +7685,40 @@ function saRows(){
|
|
|
7424
7685
|
return buildReviewDigest({ goals: gs, tasks: ts }).map((r) => {
|
|
7425
7686
|
const g = gs.find((x) => x.id === r.goalIds[0]);
|
|
7426
7687
|
const pt = ts.find((t) => r.goalIds.includes(t.goalId) && t.proof && (t.proof.png || t.proof.gif));
|
|
7427
|
-
//
|
|
7428
|
-
//
|
|
7429
|
-
//
|
|
7430
|
-
|
|
7431
|
-
|
|
7432
|
-
.
|
|
7433
|
-
|
|
7434
|
-
|
|
7435
|
-
|
|
7436
|
-
|
|
7688
|
+
// "What we did" (H23 §2, replaces the old Plan/Review-thread folds): one bullet
|
|
7689
|
+
// per finished requirement task's own report; falls back to the goal's plan
|
|
7690
|
+
// steps when nothing has finished yet (e.g. a plan awaiting approval — the plan
|
|
7691
|
+
// IS "what we're going to do" in that case, which is the honest content to show).
|
|
7692
|
+
const doneTasks = ts.filter((t) => r.goalIds.includes(t.goalId) && !t.reply && t.result)
|
|
7693
|
+
.sort((a, b) => (a.num ?? 0) - (b.num ?? 0));
|
|
7694
|
+
const did = doneTasks.length
|
|
7695
|
+
? doneTasks.map((t) => String(t.result).replace(/\s+/g, ' ').trim().slice(0, 160))
|
|
7696
|
+
: (Array.isArray(g?.plan) ? g.plan.filter(Boolean).map((s) => String(s).replace(/\s+/g, ' ').trim()) : []);
|
|
7697
|
+
// Sibling goals (H23 §2/§4 "did the AI drop part of what I asked"): buildReviewDigest
|
|
7698
|
+
// already collapses goals that share one PR into a single row (goalIds.length > 1)
|
|
7699
|
+
// — the real-data analog of a long ask fanning out into several tracked pieces.
|
|
7700
|
+
// The row's primary goal (goalIds[0], whose text/title drive this card) is marked
|
|
7701
|
+
// "cur"; the rest are listed so nothing reads as silently dropped.
|
|
7702
|
+
const siblings = r.goalIds.length > 1
|
|
7703
|
+
? r.goalIds.map((gid) => {
|
|
7704
|
+
const sg = gs.find((x) => x.id === gid);
|
|
7705
|
+
return { no: goalReviewNumber({ goal: sg, tasks: ts }), title: saReviewTitle(sg), cur: gid === r.goalIds[0] };
|
|
7706
|
+
})
|
|
7707
|
+
: null;
|
|
7437
7708
|
return {
|
|
7438
7709
|
ids: r.goalIds, goalId: r.goalIds[0], goal: g,
|
|
7439
7710
|
no: r.reviewNumber,
|
|
7440
|
-
title:
|
|
7711
|
+
title: saReviewTitle(g),
|
|
7441
7712
|
check: String(g?.reviewSummary?.check || r.checkLine || '').replace(/\s+/g, ' ').trim(),
|
|
7442
7713
|
goalText: String(g?.text ?? '').replace(/\s+/g, ' ').trim(),
|
|
7443
|
-
|
|
7444
|
-
thread, risk: g?.risk ?? null,
|
|
7714
|
+
did, siblings, risk: g?.risk ?? null,
|
|
7445
7715
|
note: g?.retest?.passed ? g.retest.note : null,
|
|
7446
7716
|
pr: r.pr, testResult: r.testResult,
|
|
7447
7717
|
proofTaskId: pt?.id ?? null, proofImg: pt?.proof?.png ?? pt?.proof?.gif ?? null,
|
|
7718
|
+
// Ledger before/after (docs/design/HANDOFF-review-card-evidence-first-2026-07-18.md
|
|
7719
|
+
// §3 kind==='image'): most tasks never have a "before" capture, so this is almost
|
|
7720
|
+
// always null and beforeAfterMediaHtml() falls back to the single-shot view.
|
|
7721
|
+
proofBefore: pt?.proof?.beforePng ?? pt?.proof?.beforeGif ?? null,
|
|
7448
7722
|
// Token-frugality is the product's whole pitch — surface it here too, not
|
|
7449
7723
|
// just in the per-goal review modal. buildReviewSummary computes the same
|
|
7450
7724
|
// estimate; reuse its logic directly off the goal's runUsage/runTokens.
|
|
@@ -7461,6 +7735,79 @@ function saRows(){
|
|
|
7461
7735
|
};
|
|
7462
7736
|
});
|
|
7463
7737
|
}
|
|
7738
|
+
// Meta row: PR link / token savings (real tap-to-open toggle) / Run locally — under
|
|
7739
|
+
// the title (H23 §1). Run-locally has no real per-task command yet — this is the
|
|
7740
|
+
// CDO's answer to "let me test this locally", left for CTO/engine to decide what it
|
|
7741
|
+
// actually triggers (docs/design/HANDOFF-review-card-evidence-first-2026-07-18.md
|
|
7742
|
+
// §5). Shown only for tasks with a real automated check to plausibly re-run
|
|
7743
|
+
// (it.testResult.ran), and tapping it is honest about not being wired yet rather
|
|
7744
|
+
// than pretending to execute anything — never a fake "running…" state.
|
|
7745
|
+
function saMetaRowHtml(it, i){
|
|
7746
|
+
const canRunLocal = Boolean(it.testResult?.ran);
|
|
7747
|
+
if (!it.pr && !it.tokenOpt && !canRunLocal) {
|
|
7748
|
+
return '<div class="lg-meta"><span class="none">No PR link / token-savings data on this task.</span></div>';
|
|
7749
|
+
}
|
|
7750
|
+
const chips = [];
|
|
7751
|
+
if (it.pr) chips.push(`<a class="sa-prlink sm" href="${esc(it.pr)}" target="_blank" title="Open pull request">PR #${esc((it.pr.match(/pull\/(\d+)/) ?? [, ''])[1])} ↗</a>`);
|
|
7752
|
+
if (it.tokenOpt) {
|
|
7753
|
+
const pct = Number(it.tokenOpt.percent);
|
|
7754
|
+
const label = pct > 0 ? `saved ${pct}% tokens` : 'token usage';
|
|
7755
|
+
chips.push(`<button class="tokchip" type="button" data-saact="tok:${i}">${esc(label)} <i class="tokchevron">›</i></button>`);
|
|
7756
|
+
}
|
|
7757
|
+
if (canRunLocal) chips.push(`<button class="sa-runlocal" type="button" data-saact="runlocal:${i}" title="Run this locally"><svg viewBox="0 0 24 24"><path d="M6 4l14 8-14 8V4z"/></svg>Run locally</button>`);
|
|
7758
|
+
const tokCmp = it.tokenOpt?.basis === 'baseline' ? 'vs your recent average' : 'from prompt caching';
|
|
7759
|
+
const tokTech = (it.tokenOpt?.techniques ?? []).slice(0, 4).join(' / ');
|
|
7760
|
+
const tokDetail = it.tokenOpt ? `<div class="tokdetail" id="tokdetail${i}"><span class="k">How this run used tokens</span><span class="v">${esc(tokCmp)} · ${esc(tokTech)}</span></div>` : '';
|
|
7761
|
+
const runNote = canRunLocal ? `<div class="tokdetail" id="runlocal${i}"><span class="v">No local run command is wired for this task yet.</span></div>` : '';
|
|
7762
|
+
return `<div class="lg-meta">${chips.join('')}</div>${tokDetail}${runNote}`;
|
|
7763
|
+
}
|
|
7764
|
+
// Context: closed by default, one-line verbatim-quote summary, chevron large and at
|
|
7765
|
+
// the far right (H23 §2). Quotes goal.text — the user's own words — never
|
|
7766
|
+
// task.detail/title (AI-authored; an earlier round of this design mistakenly quoted
|
|
7767
|
+
// the AI's own read on the request as "your ask" and it wasn't recognizable).
|
|
7768
|
+
function saContextHtml(it, gk){
|
|
7769
|
+
const goalTxt = it.goalText;
|
|
7770
|
+
const summary = goalTxt
|
|
7771
|
+
? `"${esc(goalTxt.length > 60 ? `${goalTxt.slice(0, 60)}…` : goalTxt)}"`
|
|
7772
|
+
: 'No verbatim ask on record for this goal.';
|
|
7773
|
+
const split = it.siblings
|
|
7774
|
+
? `<div class="lg-splitnote"><div class="hd">Split into ${it.siblings.length} goals — nothing else was dropped</div><ul>${it.siblings.map((s) => `<li class="${s.cur ? 'cur' : ''}">${s.no != null ? `#${esc(s.no)} ` : ''}${esc(s.title)}${s.cur ? ' <b>(this one)</b>' : ''}</li>`).join('')}</ul></div>`
|
|
7775
|
+
: '';
|
|
7776
|
+
const did = it.did?.length ? `<div class="lg-bullethd">What we did</div><ul class="lg-bullets">${it.did.map((s) => `<li>${esc(s)}</li>`).join('')}</ul>` : '';
|
|
7777
|
+
const look = it.look?.length ? `<div class="lg-bullethd">What to look at</div><ul class="lg-bullets">${it.look.map((s) => `<li>${esc(s)}</li>`).join('')}</ul>` : '';
|
|
7778
|
+
const inner = (split || did || look) ? `${split}${did}${look}` : '<div class="lg-notfound">Nothing else on record for this goal.</div>';
|
|
7779
|
+
const open = SA.detOpen.has(`${gk}:ctx`) ? ' open' : '';
|
|
7780
|
+
return `<details class="lg-goal" data-detkey="${gk}:ctx"${open}><summary><span class="k">Context</span><span class="v">${summary}</span><i class="gtoggle">›</i></summary><div class="lg-context">${inner}</div></details>`;
|
|
7781
|
+
}
|
|
7782
|
+
// Evidence hero: shape follows task kind, not one image-shaped template (H23 §3).
|
|
7783
|
+
// Real data check: most finished tasks have no proof image at all (logic-only /
|
|
7784
|
+
// docs-only / research changes verify without a screenshot) — kind is derived, see
|
|
7785
|
+
// saEvidenceKind() above.
|
|
7786
|
+
function saEvidenceHtml(kind, factRow, img, filePaths, diffText, gtr, it){
|
|
7787
|
+
if (kind === 'logic') {
|
|
7788
|
+
const passed = gtr?.passed ?? 0, failed = gtr?.failed ?? 0, ok = gtr?.ok !== false;
|
|
7789
|
+
const n = ok ? String(passed) : `${passed}/${passed + failed}`;
|
|
7790
|
+
return `<div class="lg-ev"><div class="lg-evhd">Evidence — tests, no UI to show</div>
|
|
7791
|
+
<div class="lg-herostat${ok ? '' : ' fail'}"><span class="n">${esc(n)}</span><span class="l">tests passed<br>${esc(filePaths.join(', '))}</span></div></div>`;
|
|
7792
|
+
}
|
|
7793
|
+
if (kind === 'docs') {
|
|
7794
|
+
const lines = diffText ? diffText.split('\n').filter((l) => l.length) : [];
|
|
7795
|
+
const diffHtml = lines.map((l) => {
|
|
7796
|
+
const isAdd = l.startsWith('+');
|
|
7797
|
+
return `<div class="ln${isAdd ? ' a' : ''}"><span class="gutter">+</span><span class="txt">${esc(l.slice(1) || l)}</span></div>`;
|
|
7798
|
+
}).join('');
|
|
7799
|
+
return `<div class="lg-ev"><div class="lg-evhd">Evidence — the diff, no test to run</div>
|
|
7800
|
+
<div class="row"><span class="fls">${esc(filePaths.join(', '))} · docs-only, no test target</span></div>
|
|
7801
|
+
${diffHtml ? `<div class="lg-diffblock">${diffHtml}</div>` : ''}</div>`;
|
|
7802
|
+
}
|
|
7803
|
+
if (kind === 'missing') {
|
|
7804
|
+
const note = it.check || 'Please verify this change manually — no automated capture exists for it.';
|
|
7805
|
+
return `<div class="lg-ev"><div class="lg-missinghd"><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>No capture — this touched the UI and should have one</div>
|
|
7806
|
+
<div class="lg-manualnote">${esc(note)}</div></div>`;
|
|
7807
|
+
}
|
|
7808
|
+
// kind === 'image' (default)
|
|
7809
|
+
return `<div class="lg-ev"><div class="lg-evhd">Evidence</div><div class="row">${factRow}</div>${img}</div>`;
|
|
7810
|
+
}
|
|
7464
7811
|
function saItemHtml(it, i){
|
|
7465
7812
|
const done = { 1: ['ok', '✓', 'Approved'], 2: ['no', '↩', 'Dismissed — re-testing ×3'], 3: ['no', '↩', 'Feedback sent — returned to To Do'], 4: ['ar', '—', 'Archived'], 5: ['no', '↩', 'Reverted — change undone'] }[it.st];
|
|
7466
7813
|
if (done) {
|
|
@@ -7473,46 +7820,50 @@ function saItemHtml(it, i){
|
|
|
7473
7820
|
// Honest evidence chips (mirrors the vchip tested/proof/needs-you logic in
|
|
7474
7821
|
// renderReviewChecklist ~L3742-3752): absence of a signal is shown as a muted
|
|
7475
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).
|
|
7476
7825
|
const gtr = it.testResult;
|
|
7477
7826
|
const testsChip = gtr?.ran
|
|
7478
|
-
? (gtr.ok ? `<span class="
|
|
7479
|
-
: '<span class="
|
|
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>';
|
|
7480
7829
|
const hasProofImg = it.proofImg && it.proofTaskId != null;
|
|
7481
|
-
|
|
7482
|
-
|
|
7483
|
-
|
|
7484
|
-
//
|
|
7485
|
-
//
|
|
7486
|
-
//
|
|
7487
|
-
|
|
7488
|
-
|
|
7489
|
-
|
|
7490
|
-
|
|
7491
|
-
|
|
7492
|
-
|
|
7493
|
-
|
|
7494
|
-
|
|
7495
|
-
|
|
7496
|
-
|
|
7497
|
-
|
|
7830
|
+
// Before/after (H23 spec §3 kind==='image'): reuse the same beforeafter/ba-col/
|
|
7831
|
+
// balabel renderer as the older per-task checklist (beforeAfterMediaHtml, ~L3281).
|
|
7832
|
+
// it.proofBefore is null for the ~all tasks with no "before" capture, so this
|
|
7833
|
+
// falls back to the identical single <img class="proofimg"> markup it replaced.
|
|
7834
|
+
// Diff-box + zoom inset (HANDOFF-review-card-diffbox-2026-07-18) is computed async
|
|
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.
|
|
7839
|
+
const boxedImg = SA.diffBoxHtml[it.goalId];
|
|
7840
|
+
const img = hasProofImg
|
|
7841
|
+
? (typeof boxedImg === 'string' ? boxedImg : beforeAfterMediaHtml(it.proofTaskId, { png: it.proofImg, beforePng: it.proofBefore }, 'proof'))
|
|
7842
|
+
: '';
|
|
7843
|
+
// Changed-file paths feed both the evidence-kind heuristic and the docs-kind diff —
|
|
7844
|
+
// same source the old "Changed files & diff" fold used (d from the diff endpoint,
|
|
7845
|
+
// falling back to task.changedFiles when the server has no diff for this goal).
|
|
7846
|
+
let filePaths = [], nf = 0;
|
|
7847
|
+
if (d) { filePaths = d.files.map((f) => f.path); nf = filePaths.length; }
|
|
7848
|
+
else if (d === null) {
|
|
7849
|
+
filePaths = [...new Set((state.tasks || []).filter((t) => it.ids.includes(t.goalId)).flatMap((t) => t.changedFiles ?? []))];
|
|
7850
|
+
nf = filePaths.length;
|
|
7498
7851
|
}
|
|
7499
|
-
const
|
|
7852
|
+
const kind = saEvidenceKind(filePaths, hasProofImg, gtr);
|
|
7853
|
+
const factRow = [
|
|
7500
7854
|
testsChip,
|
|
7501
|
-
it.note ? `<span class="
|
|
7502
|
-
pm,
|
|
7855
|
+
it.note ? `<span class="vf ok"><i class="g">✓</i>${esc(it.note)}</span>` : '',
|
|
7503
7856
|
`<span class="fls">${nf} file${nf === 1 ? '' : 's'}</span>`,
|
|
7504
|
-
|
|
7505
|
-
hasProofImg ? '' : '<span class="vchip none">— no proof</span>',
|
|
7857
|
+
hasProofImg ? '' : '<span class="vf none">— no proof</span>',
|
|
7506
7858
|
].filter(Boolean).join('');
|
|
7507
7859
|
const gk = it.goalId;
|
|
7508
|
-
|
|
7509
|
-
|
|
7510
|
-
|
|
7511
|
-
const
|
|
7512
|
-
|
|
7513
|
-
|
|
7514
|
-
const
|
|
7515
|
-
const diffHtml = `<details class="lg-det" data-detkey="${gk}:diff"${SA.detOpen.has(`${gk}:diff`) ? ' open' : ''}><summary>Changed files & diff<span class="c">${diffCount}</span></summary><div class="lg-diffwrap"><div class="desc">${desc}</div>${files}${diffBlock}${miss}</div></details>`;
|
|
7860
|
+
// "What to look at" (H23 §2): the changed files themselves — a real, non-fabricated
|
|
7861
|
+
// pointer for the reviewer's eyes, same file list that used to live in the retired
|
|
7862
|
+
// "Changed files & diff" fold.
|
|
7863
|
+
const look = filePaths.slice(0, 8);
|
|
7864
|
+
if (filePaths.length > 8) look.push(`+${filePaths.length - 8} more`);
|
|
7865
|
+
const contextHtml = saContextHtml({ goalText: it.goalText, siblings: it.siblings, did: it.did, look }, gk);
|
|
7866
|
+
const evidenceHtml = saEvidenceHtml(kind, factRow, img, filePaths, d?.diff ?? null, gtr, it);
|
|
7516
7867
|
// shouldPause = classifyChangeRisk (engine/lib.mjs) already forced this goal to
|
|
7517
7868
|
// stop in review for exactly this reason — surface it; a non-blocking low-risk
|
|
7518
7869
|
// read isn't worth a permanent row on every card (goal 427 §4 already covers it
|
|
@@ -7523,16 +7874,9 @@ function saItemHtml(it, i){
|
|
|
7523
7874
|
: '';
|
|
7524
7875
|
return `<div class="sa-item lg${it.goalId === SA.cur ? ' cur' : ''}" data-gid="${it.goalId}">
|
|
7525
7876
|
<div class="lg-hd"><span class="no">${it.no != null ? `#${it.no}` : ''}</span><span class="t">${esc(it.title)}</span><span class="lg-state review">Review</span></div>
|
|
7526
|
-
|
|
7527
|
-
<div class="lg-
|
|
7528
|
-
|
|
7529
|
-
<div class="row">${evRow}</div>
|
|
7530
|
-
${tokenOptimizationHtml(it.tokenOpt)}
|
|
7531
|
-
${img}
|
|
7532
|
-
</div>
|
|
7533
|
-
${planHtml}
|
|
7534
|
-
${threadHtml}
|
|
7535
|
-
${diffHtml}
|
|
7877
|
+
${saMetaRowHtml(it, i)}
|
|
7878
|
+
<div class="lg-ctx">${contextHtml}<div class="lg-fact"><span class="k">Done when</span><span class="v">${esc(it.check)}</span></div></div>
|
|
7879
|
+
${evidenceHtml}
|
|
7536
7880
|
${riskHtml}
|
|
7537
7881
|
<div class="sa-act"><button class="sa-app" data-saact="app:${i}">${SA_SVG.ok}Approve</button><button class="sa-dis" data-saact="dis:${i}">Dismiss</button><div class="sa-chatwrap"><input class="sa-chat" data-i="${i}" placeholder="What's wrong? Send back to Claude Code…"><button class="sa-send" data-saact="send:${i}" title="Send back"><svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button></div><button class="sa-ic" title="Archive" data-saact="arch:${i}">${SA_SVG.arch}</button><button class="sa-ic" title="Revert" data-saact="rev:${i}">${SA_SVG.trash}</button></div>
|
|
7538
7882
|
</div>`;
|
|
@@ -7550,7 +7894,7 @@ function saRender(){
|
|
|
7550
7894
|
// Native <details> would collapse on every rebuild (SSE tick, approve, etc.) —
|
|
7551
7895
|
// track what's open per goal+section in SA.detOpen (persists across re-renders)
|
|
7552
7896
|
// and wire each one's toggle to keep that set current.
|
|
7553
|
-
for (const det of S.querySelectorAll('.lg-
|
|
7897
|
+
for (const det of S.querySelectorAll('.lg-goal[data-detkey]')) {
|
|
7554
7898
|
det.addEventListener('toggle', () => {
|
|
7555
7899
|
const k = det.dataset.detkey;
|
|
7556
7900
|
if (det.open) SA.detOpen.add(k); else SA.detOpen.delete(k);
|
|
@@ -7597,6 +7941,25 @@ async function saFetchDiffs(){
|
|
|
7597
7941
|
}));
|
|
7598
7942
|
if (SA.open) saSync();
|
|
7599
7943
|
}
|
|
7944
|
+
// Diff-box + zoom inset (HANDOFF-review-card-diffbox-2026-07-18): unlike
|
|
7945
|
+
// saFetchDiffs above (whose fetch settling triggers a full saSync()/saRender()), this
|
|
7946
|
+
// patches each card's already-rendered .beforeafter node directly — a full re-render
|
|
7947
|
+
// here would reset scroll, close every open <details>, and drop an in-progress reply
|
|
7948
|
+
// keystroke, all just to draw a box around one screenshot. SA.diffBoxHtml is still
|
|
7949
|
+
// cached per goal, so if a later SSE-driven saSync() does rebuild the card anyway,
|
|
7950
|
+
// saItemHtml() picks the cached result back up with no flicker to the plain pair.
|
|
7951
|
+
async function saEnhanceDiffBoxes(){
|
|
7952
|
+
const targets = SA.items.filter((it) => it.proofImg && it.proofTaskId != null && it.proofBefore && SA.diffBoxHtml[it.goalId] === undefined);
|
|
7953
|
+
await Promise.all(targets.map(async (it) => {
|
|
7954
|
+
let html = null;
|
|
7955
|
+
try { html = await beforeAfterBoxedHtml(it.proofTaskId, { png: it.proofImg, beforePng: it.proofBefore }, esc(it.title ?? '')); }
|
|
7956
|
+
catch { html = null; } // a decode/pixel-diff failure is a fallback, never a crash
|
|
7957
|
+
SA.diffBoxHtml[it.goalId] = html; // cache even on null — never re-attempt an already-answered "no box"
|
|
7958
|
+
if (!html || !SA.open) return;
|
|
7959
|
+
const host = document.querySelector(`.sa-item[data-gid="${it.goalId}"] .beforeafter`);
|
|
7960
|
+
if (host) host.outerHTML = html; // html itself opens with the replacement .beforeafter
|
|
7961
|
+
}));
|
|
7962
|
+
}
|
|
7600
7963
|
function saOpen(focusGoalId){
|
|
7601
7964
|
SA.open = true;
|
|
7602
7965
|
SA.items = saRows().map((r) => ({ ...r, st: 0 }));
|
|
@@ -7618,6 +7981,7 @@ function saOpen(focusGoalId){
|
|
|
7618
7981
|
saRender();
|
|
7619
7982
|
$('seeall').classList.add('open');
|
|
7620
7983
|
saFetchDiffs();
|
|
7984
|
+
saEnhanceDiffBoxes();
|
|
7621
7985
|
}
|
|
7622
7986
|
function saClose(){ SA.open = false; SA.focusGid = null; if (SA.focusTimer) { clearTimeout(SA.focusTimer); SA.focusTimer = null; } $('seeall').classList.remove('open'); }
|
|
7623
7987
|
// Continuous review (Masa 2026-07-08): move a persistent "current" card with
|
|
@@ -7676,6 +8040,10 @@ $('seeall').addEventListener('click', async (e) => {
|
|
|
7676
8040
|
const it = SA.items[i];
|
|
7677
8041
|
if (k === 'close') return saClose();
|
|
7678
8042
|
if (!it) return;
|
|
8043
|
+
// meta-row toggles (H23): tap the token chip to reveal the technique breakdown, tap
|
|
8044
|
+
// Run-locally to reveal the honest "not wired yet" note — neither performs an action.
|
|
8045
|
+
if (k === 'tok') { b.classList.toggle('open'); $('seeall').querySelector(`#tokdetail${i}`)?.classList.toggle('open'); return; }
|
|
8046
|
+
if (k === 'runlocal') { $('seeall').querySelector(`#runlocal${i}`)?.classList.toggle('open'); return; }
|
|
7679
8047
|
// every verdict auto-advances the focus to the next pending card (tempo).
|
|
7680
8048
|
if (k === 'app') { const bx = b.getBoundingClientRect(); confettiBurst(bx.left + bx.width / 2, bx.top + bx.height / 2); return saAct(it, 1, 'approve').then((ok) => { if (ok) saAdvanceFocus(it.goalId); }); }
|
|
7681
8049
|
if (k === 'dis') return saAct(it, 2, 'retest').then((ok) => { if (ok) saAdvanceFocus(it.goalId); });
|