@galda/cli 0.10.37 → 0.10.39
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/CLAUDE.md +7 -3
- package/app/index.html +742 -401
- package/engine/lib.mjs +344 -37
- package/engine/server.mjs +248 -248
- package/engine/shot.mjs +96 -0
- package/package.json +1 -1
package/app/index.html
CHANGED
|
@@ -853,20 +853,15 @@
|
|
|
853
853
|
.reqdesc .mdsp{height:6px}
|
|
854
854
|
.reqdesc code,.gdtaskresult code{font-family:var(--mono);font-size:11px;background:rgba(var(--tint),.07);padding:1px 4px;border-radius:4px}
|
|
855
855
|
.reqdesc strong{color:var(--ink);font-weight:700}
|
|
856
|
+
/* an image the AI pasted into its own report: shown full-width at its own aspect,
|
|
857
|
+
hairline edge only (§5.5 縁は1px以下のヘアライン), never a fixed-ratio crop box */
|
|
858
|
+
.mdimg{display:block;max-width:100%;height:auto;margin:8px 0;border-radius:8px;border:1px solid var(--hair)}
|
|
856
859
|
.reqcond{font-family:var(--mono);font-size:11px;color:var(--ink3);margin-top:4px}
|
|
857
860
|
.reqvideo{margin:10px 0}
|
|
858
861
|
.reqnovideo{font-size:12px;color:var(--ink3);border:1px dashed var(--hair2);border-radius:10px;padding:16px;text-align:center;margin:10px 0}
|
|
859
862
|
.beforeafter{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
|
860
863
|
.ba-col{min-width:0}
|
|
861
864
|
.balabel{display:block;font-family:var(--mono);font-size:9px;letter-spacing:.06em;color:var(--ink3);margin-bottom:2px}
|
|
862
|
-
/* diff box + zoomed inset (HANDOFF-review-card-diffbox-2026-07-18) — only emitted
|
|
863
|
-
by beforeAfterBoxedHtml(), so these never touch the plain pair above or the
|
|
864
|
-
older per-task renderReviewChecklist before/after (out of scope, untouched). */
|
|
865
|
-
.ba-imgwrap{position:relative;margin-top:8px}
|
|
866
|
-
.ba-imgwrap .proofimg{width:100%;height:auto;max-height:none;object-fit:initial;margin-top:0;display:block}
|
|
867
|
-
.ba-box{position:absolute;border:1.5px solid var(--danger);border-radius:2px;pointer-events:none}
|
|
868
|
-
.ba-zoomhd{margin-top:14px}
|
|
869
|
-
.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)}
|
|
870
865
|
.reqbtns{display:flex;gap:8px;margin-top:4px}
|
|
871
866
|
.reqbtn{height:30px;padding:0 14px;border-radius:9px;border:1px solid var(--hair2);background:transparent;
|
|
872
867
|
color:var(--ink2);font:inherit;font-size:12.5px;font-weight:600;cursor:pointer;transition:.15s}
|
|
@@ -1620,6 +1615,14 @@
|
|
|
1620
1615
|
.lbc.is-quiet .lbc-t{color:var(--ink2)} /* Done / Later / To Do: visible, not shouting */
|
|
1621
1616
|
.lbc-w{font:600 8.5px/1 var(--mono);letter-spacing:.11em;text-transform:uppercase;margin-left:auto}
|
|
1622
1617
|
.lbc-w.bad{color:var(--danger)} .lbc-w.intr{color:#ffca16}
|
|
1618
|
+
/* P0b: the AI's own To-do breakdown, scaled down to the card's density (§1.5-8 fold, not
|
|
1619
|
+
scroll — capped + "+N more" text rather than an inner scrollbar). Reuses the .todo/.box/
|
|
1620
|
+
.tdtext vocabulary from the chat feed (todosHtml) so the ✓/▸/○ language stays one system. */
|
|
1621
|
+
.lbc-todos{display:flex;flex-direction:column;gap:3px}
|
|
1622
|
+
.lbc-todos .todo{font-size:10.5px;line-height:1.4;gap:5px}
|
|
1623
|
+
.lbc-todos .todo .box{width:11px;height:11px;font-size:9px;line-height:11px;margin-top:1px}
|
|
1624
|
+
.lbc-todos.prep{font-size:10.5px;color:var(--ink3);font-style:italic}
|
|
1625
|
+
.lbc-more{font:500 9.5px/1 var(--mono);color:var(--ink3)}
|
|
1623
1626
|
.lbc-meter{display:flex;align-items:center;gap:7px}
|
|
1624
1627
|
.lbc-meter .bar{flex:1;height:2px;border-radius:2px;background:color-mix(in srgb,var(--ink) 11%,transparent);overflow:hidden}
|
|
1625
1628
|
.lbc-meter .bar i{display:block;height:100%;border-radius:2px;background:var(--green);transition:width .3s}
|
|
@@ -1712,33 +1715,12 @@
|
|
|
1712
1715
|
/* review row number — #NN PREFIX on the title (HANDOFF-v45 §11), same form as the To Do
|
|
1713
1716
|
rows (PRD §5: numbering matches the sidebar) */
|
|
1714
1717
|
#fsRoot .rcard .rid{font-size:10.5px;font-weight:600;color:var(--ink3);margin:0 5px 0 0;display:inline-block;font-variant-numeric:tabular-nums}
|
|
1715
|
-
/* PRD §6.5
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
#fsRoot .rcard .fnote{font-family:var(--mono);font-size:9.5px;color:var(--ink3);margin-top:3px;display:block}
|
|
1722
|
-
/* In rv3 the meta sits on the near-black photo scrim whatever the theme, where --ink3
|
|
1723
|
-
is effectively invisible (§5.5「見えるが騒がない」— near-invisible is a fail). Match
|
|
1724
|
-
.rid's white-on-scrim treatment. The folded card opts out of the scrim (rule above),
|
|
1725
|
-
so it keeps --ink3 on the panel where that tone is correct. */
|
|
1726
|
-
#fsRoot[data-rv="3"] .rcard:not(.fold) .fnote{color:rgba(255,255,255,.66);text-shadow:0 1px 3px rgba(0,0,0,.55)}
|
|
1727
|
-
/* A folded card is settled, not failed: title sinks to the settled tone, and it
|
|
1728
|
-
keeps no ✓/✕ because the thing to approve lives on the goal it was folded into. */
|
|
1729
|
-
#fsRoot .rcard.fold .t{color:var(--ink2)}
|
|
1730
|
-
/* A folded card is a pointer, not a review item: no proof to show, nothing to approve.
|
|
1731
|
-
So it opts OUT of the rv3 thumbnail treatment (whose .rc is absolutely positioned
|
|
1732
|
-
over an 84px photo ground) and stays the same quiet line in every view — the form
|
|
1733
|
-
Masa drew. Giving it a fake dark thumbnail would claim it has something to look at. */
|
|
1734
|
-
/* .fold.noimg, not .fold: the placeholder-ground rule below is `.rcard.noimg`, which
|
|
1735
|
-
ties this on specificity and would win on order — a folded card would keep the fake
|
|
1736
|
-
dark thumbnail ground. Matching both classes settles it regardless of order. */
|
|
1737
|
-
#fsRoot[data-rv="3"] .rcard.fold.noimg{display:flex;position:static;align-items:center;gap:10px;
|
|
1738
|
-
padding:9px 10px;min-height:0;border:1px solid var(--hair);border-radius:11px;background:none;box-shadow:none}
|
|
1739
|
-
#fsRoot[data-rv="3"] .rcard.fold.noimg .rc{position:static;padding:0;background:none}
|
|
1740
|
-
#fsRoot[data-rv="3"] .rcard.fold.noimg .t{color:var(--ink2);font-size:12.5px;text-shadow:none}
|
|
1741
|
-
#fsRoot[data-rv="3"] .rcard.fold.noimg .rid{color:var(--ink3)}
|
|
1718
|
+
/* PRD §6.5 → Masa 2026-07-21: the fold note ("also carries #13" on the carrier card,
|
|
1719
|
+
"merged into #12" on the folded card) no longer renders in the Review list at all —
|
|
1720
|
+
it read as the same report-in-the-list clutter the feature was meant to avoid. Where
|
|
1721
|
+
folded work went is now logged in the opened detail's "Also carries" section only, so
|
|
1722
|
+
the .fnote / .rcard.fold* rules that used to style these list-level notes are gone
|
|
1723
|
+
with them (dead CSS orphaned the moment nothing emits those classes any more). */
|
|
1742
1724
|
/* reference numbers everywhere else (HANDOFF-v45 §11): a leading #NN prefix on every
|
|
1743
1725
|
To Do item's name — same goal = same number (goalReviewNumber), distinct from the
|
|
1744
1726
|
PR's own #id shown separately. */
|
|
@@ -1948,6 +1930,198 @@
|
|
|
1948
1930
|
.sa-done .undo{margin-left:auto;border:0;background:none;color:var(--ink3);font:500 10px/1 var(--mono);cursor:pointer}
|
|
1949
1931
|
.sa-done .undo:hover{color:var(--ink);background:transparent}
|
|
1950
1932
|
.sa-empty{font:500 11px/1 var(--mono);color:var(--ink3);padding:20px 0}
|
|
1933
|
+
/* ═══ Review card redesign (REVIEW-CARD-SPEC.md · Masa 2026-07-20 confirmed):
|
|
1934
|
+
Linear-docs typography · META = green outline pill (Chip) · GitHub diff = Bright.
|
|
1935
|
+
Ported from the flagship redesign (fs-redesign.css), the dev-only PLAN/META/DIFF
|
|
1936
|
+
switchers resolved to the confirmed defaults plan=d / meta=a / gh=d, and the
|
|
1937
|
+
flagship fallback hexes mapped to the app's real theme tokens (theme.css).
|
|
1938
|
+
Scoped to .sa-item.redesign so the plain .sa-done one-liner is untouched. ═══ */
|
|
1939
|
+
#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)}
|
|
1940
|
+
/* body/header/card geometry ported verbatim from the artifact (fs-redesign.css plan=d):
|
|
1941
|
+
the CARD carries the 40px side gutters + 28/34 top/bottom, the header matches (40/34),
|
|
1942
|
+
and the See-all body drops its own gutter/gap/centering so the card padding is the
|
|
1943
|
+
single source of the Linear-docs air. !important because the app's own .sa-item /
|
|
1944
|
+
.sa-body / .lg rules (added first, same-or-higher specificity) otherwise win. */
|
|
1945
|
+
#seeall .sa-body:has(.sa-item.redesign){gap:0 !important;align-items:stretch !important}
|
|
1946
|
+
#seeall .sa-panel .sa-hd:has(~ .sa-body .sa-item.redesign){padding:16px 34px 12px 40px !important}
|
|
1947
|
+
.sa-item.redesign{width:100% !important;background:none !important;border:0 !important;border-radius:0 !important;box-shadow:none !important;
|
|
1948
|
+
padding:28px 40px 34px !important;font-family:var(--ui);color:var(--ink);line-height:1.6}
|
|
1949
|
+
.sa-item.redesign *{box-sizing:border-box}
|
|
1950
|
+
/* first card: no extra top margin — the card's own 28px padding-top (plus the
|
|
1951
|
+
body's 16px) is the top air, matching the ~40px inter-card rhythm below.
|
|
1952
|
+
The old 52px pushed the header→#N gap to ~96px of dead whitespace (Masa 2026-07-20). */
|
|
1953
|
+
#seeall .sa-body>.sa-item.redesign:first-child{margin-top:0 !important}
|
|
1954
|
+
#seeall .sa-body>.sa-item.redesign+.sa-item.redesign{border-top:1px solid var(--hair) !important;margin-top:0 !important;padding-top:40px !important}
|
|
1955
|
+
/* reply composer: match the flagship saItem field (34px tall · radius 10 · 12px left pad)
|
|
1956
|
+
so it doesn't read cramped next to the 32px Approve/Dismiss (Masa 2026-07-20).
|
|
1957
|
+
padding-right (42px for the send ring) stays from .sa-chatwrap .sa-chat. */
|
|
1958
|
+
#seeall .sa-chat{height:34px;border-radius:10px;padding-left:12px}
|
|
1959
|
+
/* header — mono eyebrow · review dot · In review */
|
|
1960
|
+
.redesign .rhead{display:flex;align-items:center;gap:12px;font-family:var(--mono);font-size:11px;color:var(--ink3)}
|
|
1961
|
+
.redesign .rtag{letter-spacing:.02em;font-variant-numeric:tabular-nums}
|
|
1962
|
+
.redesign .rst{display:inline-flex;align-items:center;gap:6px;color:var(--ink2)}
|
|
1963
|
+
.redesign .rdot{width:6px;height:6px;border-radius:50%;background:var(--amber,#e0a021);display:inline-block}
|
|
1964
|
+
.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)}
|
|
1965
|
+
/* meta — Linear sans, green/neutral chips, hairline under (spec §4) */
|
|
1966
|
+
.redesign .rmeta{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;margin:12px 0 0 !important;padding-bottom:12px !important;
|
|
1967
|
+
border-bottom:1px solid var(--hair);font-family:var(--ui);font-size:12px;letter-spacing:-.004em;color:var(--ink2)}
|
|
1968
|
+
.redesign .rmeta .mx{display:inline-flex;align-items:center;gap:5px}
|
|
1969
|
+
.redesign .rmeta .mk{color:var(--ink3)}
|
|
1970
|
+
.redesign .rmeta b{color:var(--ink);font-weight:600}
|
|
1971
|
+
.redesign .rmeta .mmut{color:var(--ink3);font-weight:400}
|
|
1972
|
+
.redesign .rmeta .ar{font-size:.82em;margin-left:1px;color:var(--ink3)}
|
|
1973
|
+
.redesign .rmeta .a{color:#3fd07a;font-style:normal} .redesign .rmeta .d{color:#ff7b72;font-style:normal}
|
|
1974
|
+
/* Chip = outline pill (meta=a): green for PR/Token, neutral for Local */
|
|
1975
|
+
.redesign .rmeta .mlink{cursor:pointer;padding:3px 10px;border-radius:999px;
|
|
1976
|
+
border:1px solid color-mix(in srgb,currentColor 40%,var(--hair));text-decoration:none;background:none;font:inherit}
|
|
1977
|
+
.redesign .rmeta .mlink:hover{background:color-mix(in srgb,currentColor 9%,transparent)}
|
|
1978
|
+
.redesign .rmeta .mlink.pr,.redesign .rmeta .mlink.pr *{color:var(--green)}
|
|
1979
|
+
/* whole PR chip bold, not just the number (REVIEW-CARD-SPEC §5) — scoped to .mlink.pr so Token/Local chips are unaffected */
|
|
1980
|
+
.redesign .rmeta .mlink.pr,.redesign .rmeta .mlink.pr .mk,.redesign .rmeta .mlink.pr b,.redesign .rmeta .mlink.pr .mmut{font-weight:700}
|
|
1981
|
+
.redesign .rmeta .mlink:not(.pr){color:var(--ink2)}
|
|
1982
|
+
.redesign .rmeta .mlink:not(.pr) .mk{color:var(--ink3)}
|
|
1983
|
+
.redesign .rmeta .mlink:not(.pr) b{color:var(--ink)}
|
|
1984
|
+
.redesign .rmeta .mtok{cursor:pointer;padding:3px 10px;border-radius:999px;font:inherit;
|
|
1985
|
+
border:1px solid color-mix(in srgb,var(--green) 40%,var(--hair));background:none}
|
|
1986
|
+
.redesign .rmeta .mtok,.redesign .rmeta .mtok *{color:var(--green)}
|
|
1987
|
+
.redesign .rmeta .mtok b{font-weight:700}
|
|
1988
|
+
.redesign .rmeta .ok{color:var(--green)}
|
|
1989
|
+
.redesign .rmeta .tokchevron{font-style:normal;font-size:11px;line-height:1;transition:transform .2s;opacity:.8}
|
|
1990
|
+
.redesign .rmeta .mtok.open .tokchevron{transform:rotate(90deg)}
|
|
1991
|
+
.redesign .rmeta-detail{display:none;margin:12px 0 0;padding:9px 12px;background:color-mix(in srgb,var(--ink) 4%,transparent);border-radius:8px}
|
|
1992
|
+
.redesign .rmeta-detail.open{display:block}
|
|
1993
|
+
.redesign .rmeta-detail .k{font:600 8px/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--ink3);margin-bottom:5px;display:block}
|
|
1994
|
+
.redesign .rmeta-detail .v{font:500 11.5px/1.6 var(--ui);color:var(--ink2)}
|
|
1995
|
+
/* Open it section: the address is the control. Mono so it reads as an address, plain
|
|
1996
|
+
text weight so it doesn't shout, and hover moves the GLYPH colour only — never a
|
|
1997
|
+
filled surface (§5.5 アイコンボタンのhoverは面を塗らない). */
|
|
1998
|
+
.redesign .ropen .olink{display:inline-flex;align-items:baseline;gap:6px;padding:0;border:0;background:none;cursor:pointer;
|
|
1999
|
+
font:400 13.5px/1.62 var(--mono);color:var(--ink);letter-spacing:-.004em;text-align:left;overflow-wrap:anywhere;transition:color .14s}
|
|
2000
|
+
.redesign .ropen .olink:hover{color:var(--green)}
|
|
2001
|
+
.redesign .ropen .olink[disabled]{color:var(--ink3);cursor:default}
|
|
2002
|
+
.redesign .ropen .olink .ar{font-size:.85em;color:var(--ink3)}
|
|
2003
|
+
/* the note is body prose like every other section's — same 14px / −.012em / --ink as
|
|
2004
|
+
.rbody (Masa 2026-07-22: it was a shade lighter and read as a different kind of text) */
|
|
2005
|
+
.redesign .ropen .v{margin:4px 0 0 !important}
|
|
2006
|
+
/* sections — Linear-docs bold sentence-case headings + high-contrast body.
|
|
2007
|
+
margins are !important: the app's pre-existing .lg / .sa-item rules load first and
|
|
2008
|
+
otherwise tie/beat these on specificity, flattening the Linear-docs rhythm. */
|
|
2009
|
+
.redesign .rsec{margin-top:24px !important}
|
|
2010
|
+
.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}
|
|
2011
|
+
/* body prose = the app's normal text: 14px / −.012em, matching .bubble (Masa 2026-07-20:
|
|
2012
|
+
13px read thinner/lighter for Japanese than the surrounding app). §2. */
|
|
2013
|
+
.redesign .rbody{font-size:14px;line-height:1.62;letter-spacing:-.012em;color:var(--ink);max-width:70ch;margin:0 !important}
|
|
2014
|
+
.redesign .rdesc,.redesign .rrisk{color:var(--ink2)}
|
|
2015
|
+
/* Result (P1, Masa 2026-07-21): the AI's own report, rendered as written. Same 14px
|
|
2016
|
+
body rhythm as .rbody — it is prose the reviewer reads, not a log. Headings/lists/
|
|
2017
|
+
code inside it are the report's own structure, so they get the section's type scale
|
|
2018
|
+
rather than the smaller .reqdesc one used in the older checklist dialog. */
|
|
2019
|
+
.redesign .rreport{font-size:14px;line-height:1.62;letter-spacing:-.012em;color:var(--ink);max-width:70ch;overflow-wrap:anywhere}
|
|
2020
|
+
.redesign .rreport .mdh{font-weight:680;color:var(--ink);margin:14px 0 4px}
|
|
2021
|
+
.redesign .rreport .mdh:first-child{margin-top:0}
|
|
2022
|
+
.redesign .rreport .mdul{margin:2px 0;padding-left:20px}
|
|
2023
|
+
.redesign .rreport .mdul li{margin:2px 0}
|
|
2024
|
+
.redesign .rreport .mdsp{height:8px}
|
|
2025
|
+
.redesign .rreport code{font-family:var(--mono);font-size:12px;background:rgba(var(--tint),.07);padding:1px 4px;border-radius:4px}
|
|
2026
|
+
.redesign .rreport strong{color:var(--ink);font-weight:700}
|
|
2027
|
+
.redesign .rreport a{color:var(--ink);text-decoration:underline;text-underline-offset:2px}
|
|
2028
|
+
/* "Open it" is waiting on a real process to boot (up to ~15s), so the chip says so
|
|
2029
|
+
rather than looking dead. Dim + no pointer, no spinner — §5.5 薄く・主張しない. */
|
|
2030
|
+
.redesign .mx.mwait{opacity:.55;pointer-events:none}
|
|
2031
|
+
.redesign .rfiles{margin:12px 0 0;font-family:var(--mono);font-size:12px;color:var(--ink2);line-height:1.75}
|
|
2032
|
+
.redesign .rfiles .a{color:#3fd07a;font-style:normal}.redesign .rfiles .d{color:#ff7b72;font-style:normal}
|
|
2033
|
+
/* Request accordion (spec §3) — chevron › rotate 90, body indented, no left rule */
|
|
2034
|
+
.redesign .rreq .rsec-h{margin-bottom:6px !important}
|
|
2035
|
+
.redesign .reqacc{margin-top:0 !important}
|
|
2036
|
+
.redesign .racc>summary{list-style:none;cursor:pointer;display:flex;gap:10px;align-items:baseline}
|
|
2037
|
+
.redesign .racc>summary::-webkit-details-marker{display:none}
|
|
2038
|
+
.redesign .racc>summary::before{content:"›";font-size:1.05em;line-height:1;color:var(--ink2);display:inline-block;transition:transform .14s}
|
|
2039
|
+
.redesign .racc[open]>summary::before{transform:rotate(90deg)}
|
|
2040
|
+
.redesign .reqacc>summary{color:var(--ink);font-size:13.5px;line-height:1.55}
|
|
2041
|
+
.redesign .reqbody{margin:14px 0 0 !important;padding-left:22px !important}
|
|
2042
|
+
.redesign .rsub{font-family:var(--ui);font-size:11px;font-weight:600;color:var(--ink3);margin:16px 0 8px !important}
|
|
2043
|
+
.redesign .reqbody>.rsub:first-child{margin-top:2px !important}
|
|
2044
|
+
.redesign .reqbody .lg-bullets{margin:0;padding:0 0 0 16px;font-size:13px;line-height:1.65;color:var(--ink2)}
|
|
2045
|
+
.redesign .reqbody .lg-bullets li{margin-bottom:5px}
|
|
2046
|
+
.redesign .reqbody .lg-splitnote{background:color-mix(in srgb,var(--ink) 4%,transparent);border-radius:8px;padding:10px 12px;margin-top:12px}
|
|
2047
|
+
.redesign .reqbody .lg-splitnote .hd{font:600 8.5px/1 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--ink3);margin-bottom:7px}
|
|
2048
|
+
.redesign .reqbody .lg-splitnote ul{margin:0;padding:0;list-style:none;font-size:12px;line-height:1.8;color:var(--ink3)}
|
|
2049
|
+
.redesign .reqbody .lg-splitnote li.cur{color:var(--ink)} .redesign .reqbody .lg-splitnote li.cur b{color:var(--green);font-weight:600}
|
|
2050
|
+
/* workflow (spec §8) — vertical ✓ green / → green / □ dim */
|
|
2051
|
+
.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)}
|
|
2052
|
+
.redesign .rwf .wf-s{display:flex;align-items:center;gap:9px}
|
|
2053
|
+
.redesign .rwf .wf-s .mk{width:12px;text-align:center;flex:0 0 12px}
|
|
2054
|
+
.redesign .rwf .wf-s.ok{color:var(--ink2)}.redesign .rwf .wf-s.ok .mk{color:var(--green)}
|
|
2055
|
+
.redesign .rwf .wf-s.now{color:var(--ink);font-weight:600}.redesign .rwf .wf-s.now .mk{color:var(--green);font-weight:700}
|
|
2056
|
+
.redesign .rwf .wf-s.todo{color:var(--ink3)}.redesign .rwf .wf-s.todo .mk{color:var(--ink3);font-size:.82em}
|
|
2057
|
+
/* proof (spec §6) — before/after forced same size · run-log · honest missing note */
|
|
2058
|
+
.redesign .beforeafter{display:grid;grid-template-columns:1fr 1fr;gap:12px;max-width:640px;align-items:start}
|
|
2059
|
+
.redesign .ba-col{display:flex;flex-direction:column;gap:6px;min-width:0}
|
|
2060
|
+
.redesign .balabel{font-family:var(--mono);font-size:11px;letter-spacing:.04em;text-transform:uppercase;color:var(--ink3)}
|
|
2061
|
+
.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}
|
|
2062
|
+
.redesign .rproof{max-width:640px}
|
|
2063
|
+
/* screen-recording player (spec §6) — sits under before/after, same 640px column */
|
|
2064
|
+
.redesign .rvid{margin:12px 0 0;max-width:640px}
|
|
2065
|
+
.redesign .rvid svg,.redesign .rvid .proofvid{display:block;width:100%;height:auto;border:1px solid var(--hair);border-radius:9px;background:#000}
|
|
2066
|
+
.redesign .rvid .proofvid{aspect-ratio:16/9;object-fit:contain}
|
|
2067
|
+
.redesign .rvid figcaption{margin-top:7px;font-family:var(--mono);font-size:11px;letter-spacing:.02em;color:var(--ink3)}
|
|
2068
|
+
.redesign .rrun{border:1px solid var(--hair);border-radius:10px;overflow:hidden;font-family:var(--mono);font-size:12.5px;max-width:640px}
|
|
2069
|
+
.redesign .rrun .rr{display:flex;justify-content:space-between;gap:16px;padding:10px 14px;border-top:1px solid var(--hair)}
|
|
2070
|
+
.redesign .rrun .rr:first-child{border-top:0}
|
|
2071
|
+
.redesign .rrun .rr .lab{color:var(--ink2)}.redesign .rrun .rr .v{color:var(--green);text-align:right}
|
|
2072
|
+
.redesign .rmiss{display:flex;align-items:flex-start;gap:8px;font-size:12.5px;line-height:1.6;color:var(--ink2)}
|
|
2073
|
+
.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}
|
|
2074
|
+
/* GitHub diff = Bright (gh=d): old|new gutters · +/− column · line+gutter tint */
|
|
2075
|
+
.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}
|
|
2076
|
+
.redesign .ghl{display:grid;grid-template-columns:46px 46px 22px 1fr}
|
|
2077
|
+
.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}
|
|
2078
|
+
.redesign .ghl .ghm{text-align:center;color:var(--ink3);padding:1px 0}
|
|
2079
|
+
.redesign .ghl .ghc{padding:1px 10px;white-space:pre-wrap;color:var(--ink)}
|
|
2080
|
+
.redesign .gh-add{background:rgba(46,204,113,.22)}
|
|
2081
|
+
.redesign .gh-add .gho,.redesign .gh-add .ghn{background:rgba(46,204,113,.30)}
|
|
2082
|
+
.redesign .gh-add .ghm{color:#3fb950}
|
|
2083
|
+
.redesign .gh-del{background:rgba(248,81,73,.20)}
|
|
2084
|
+
.redesign .gh-del .gho,.redesign .gh-del .ghn{background:rgba(248,81,73,.30)}
|
|
2085
|
+
.redesign .gh-del .ghm{color:#f85149}
|
|
2086
|
+
.redesign .gh-hi{border-radius:2px;padding:0 1px}
|
|
2087
|
+
.redesign .gh-add .gh-hi{background:rgba(46,204,113,.55)} .redesign .gh-del .gh-hi{background:rgba(248,81,73,.5)}
|
|
2088
|
+
/* huge-diff footer (spec §7): "…+N 行 · GitHub で開く ↗" */
|
|
2089
|
+
.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}
|
|
2090
|
+
.redesign .ghfoot .ghopen{color:var(--ink2);text-decoration:none}
|
|
2091
|
+
.redesign .ghfoot .ghopen:hover{color:var(--blue)}
|
|
2092
|
+
/* meta links carry NO underline — PR is a real <a href> (spec §5, Masa 2026-07-20) */
|
|
2093
|
+
.redesign .rmeta a{text-decoration:none}
|
|
2094
|
+
/* Request / What-to-check = full contrast, same ink as the title (spec §2, no fade) */
|
|
2095
|
+
.redesign .rask,.redesign .rcheck,.redesign .rcheck li,.redesign .rcheck1{color:var(--ink) !important}
|
|
2096
|
+
.redesign ul.rcheck{margin:2px 0 0;padding:0 0 0 18px;font-size:14px;line-height:1.7;letter-spacing:-.012em}
|
|
2097
|
+
.redesign ul.rcheck li{margin-bottom:6px}
|
|
2098
|
+
/* Activity (spec §3) = ONE Linear-docs pulldown. Heading plain; chevron on the FIRST
|
|
2099
|
+
line; tapping opens the rest below, offset-indented (no left rule); Discussion folds
|
|
2100
|
+
in at the bottom of the opened body. */
|
|
2101
|
+
.redesign .ract{margin-top:24px !important}
|
|
2102
|
+
.redesign .ract>.rsec-h{margin-bottom:10px !important}
|
|
2103
|
+
.redesign .lacc>summary{list-style:none;cursor:pointer;display:flex;gap:9px;align-items:baseline}
|
|
2104
|
+
.redesign .lacc>summary::-webkit-details-marker{display:none}
|
|
2105
|
+
.redesign .lacc .ltog{flex:0 0 auto;color:var(--ink3);font-style:normal;line-height:1;transition:transform .14s}
|
|
2106
|
+
.redesign .lacc[open]>summary .ltog{transform:rotate(90deg)}
|
|
2107
|
+
.redesign .lacc .lfirst{flex:1;min-width:0}
|
|
2108
|
+
.redesign .lacc .lbody{margin-top:12px;padding-left:18px}
|
|
2109
|
+
/* transcript rows — Linear-clean, borderless (You / tool mono / res green / Claude) */
|
|
2110
|
+
.redesign .rtx{border:0;background:none}
|
|
2111
|
+
.redesign .txr{display:flex;gap:12px;padding:5px 0;font-size:13px;line-height:1.55}
|
|
2112
|
+
.redesign .txr .w{flex:0 0 46px;font-size:11px;color:var(--ink3);padding-top:1px}
|
|
2113
|
+
.redesign .txr.you .w,.redesign .txr.cl .w{color:var(--ink);font-weight:600}
|
|
2114
|
+
.redesign .txr .m{flex:1;min-width:0;color:var(--ink2)}
|
|
2115
|
+
.redesign .txr.cl .m{color:var(--ink)}
|
|
2116
|
+
.redesign .txr.tool .m{color:var(--ink2);font-family:var(--mono);font-size:12px}
|
|
2117
|
+
.redesign .txr.tool .m b{color:var(--ink);font-weight:600}
|
|
2118
|
+
.redesign .txr.res .m{font-family:var(--mono);font-size:12px;color:var(--green)}
|
|
2119
|
+
/* Discussion (review back-and-forth) folded at the bottom of Activity's body */
|
|
2120
|
+
.redesign .lbody .rthread{display:flex;flex-direction:column;gap:16px;margin:0}
|
|
2121
|
+
.redesign .lbody .rmsg .who{font-size:12px;font-weight:600;color:var(--ink);margin-bottom:4px}
|
|
2122
|
+
.redesign .lbody .rmsg .msg{font-size:13px;line-height:1.55;color:var(--ink2)}
|
|
2123
|
+
/* sensitive-area note (real) sits below What changed */
|
|
2124
|
+
.redesign .lg-risk{margin-top:20px}
|
|
1951
2125
|
#saconf{position:fixed;inset:0;pointer-events:none;z-index:99;display:block}
|
|
1952
2126
|
/* retest ×3 note surfaces on the lane review card too (v45 §5.3) */
|
|
1953
2127
|
#fsRoot .rcard .rnote{display:block;font:500 9.5px/1.4 var(--mono);color:var(--green);margin-top:3px}
|
|
@@ -2834,7 +3008,7 @@
|
|
|
2834
3008
|
<div class="reqlist" id="reviewList"></div>
|
|
2835
3009
|
<div class="revactions">
|
|
2836
3010
|
<button type="button" class="reqbtn approve" id="reviewApprove">Approve goal</button>
|
|
2837
|
-
<button type="button" class="reqbtn dismiss" id="reviewDismiss">
|
|
3011
|
+
<button type="button" class="reqbtn dismiss" id="reviewDismiss">Dismiss</button>
|
|
2838
3012
|
<span style="flex:1"></span>
|
|
2839
3013
|
<button class="mbtn ghost" id="reviewClose">Close</button>
|
|
2840
3014
|
</div>
|
|
@@ -2904,7 +3078,7 @@
|
|
|
2904
3078
|
</div>
|
|
2905
3079
|
<div class="sa-act">
|
|
2906
3080
|
<button type="button" class="sa-app" id="gdApprove"><svg viewBox="0 0 24 24"><path d="M5 12.5l4.2 4.2L19 7"/></svg>Approve</button>
|
|
2907
|
-
<button type="button" class="sa-dis" id="gdDismiss">
|
|
3081
|
+
<button type="button" class="sa-dis" id="gdDismiss">Dismiss</button>
|
|
2908
3082
|
<div class="sa-chatwrap">
|
|
2909
3083
|
<input type="text" class="sa-chat" id="gdRevisionInput" placeholder="Tell the worker what to try next…">
|
|
2910
3084
|
<button type="button" class="sa-send" id="gdSend" title="Send" aria-label="Send"><svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button>
|
|
@@ -2989,7 +3163,7 @@ const savedTheme = (() => {
|
|
|
2989
3163
|
if (v !== s) localStorage.setItem('theme', v);
|
|
2990
3164
|
return v;
|
|
2991
3165
|
})();
|
|
2992
|
-
const state = { projects: [], goals: [], tasks: [], act: {}, actAt: {}, todos: {}, chatTurns: {}, active: '
|
|
3166
|
+
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,
|
|
2993
3167
|
// per-message send overrides set via slash-commands (reset after each send,
|
|
2994
3168
|
// except model which is sticky in localStorage). palette = open command list.
|
|
2995
3169
|
msg: { mode: null, model: null, effort: null, skill: null, later: false, review: false },
|
|
@@ -3022,7 +3196,7 @@ function restoreFocusedAnswerInput(container, attr, saved){
|
|
|
3022
3196
|
const STATUS_LABEL = {
|
|
3023
3197
|
sending: 'Sending…', stacked: 'Queued', planning: 'Planning…', queued: 'To do', running: 'Working…', needsInput: 'Needs input',
|
|
3024
3198
|
review: 'In review', done: 'Done', failed: 'Failed', partial: 'Partly failed', interrupted: 'Interrupted', skipped: 'Skipped',
|
|
3025
|
-
rejected: '
|
|
3199
|
+
rejected: 'Dismissed',
|
|
3026
3200
|
};
|
|
3027
3201
|
// Mirrors engine/lib.mjs goalChip() — kept in sync manually since this
|
|
3028
3202
|
// inline script can't import server-side code. `columns` (a project's
|
|
@@ -3041,7 +3215,7 @@ const GOAL_CHIP = (s, columns, startedAt) => s === 'stacked' || s === 'sending'
|
|
|
3041
3215
|
s === 'done' ? [pickColumnLabel(columns, 'done', 'DONE'), ''] :
|
|
3042
3216
|
s === 'retesting' ? ['RE-TESTING', 'doing'] :
|
|
3043
3217
|
s === 'reverted' ? ['REVERTED', ''] :
|
|
3044
|
-
s === 'rejected' ? ['
|
|
3218
|
+
s === 'rejected' ? ['DISMISSED', 'rejected'] :
|
|
3045
3219
|
s === 'folded' ? ['FOLDED', ''] :
|
|
3046
3220
|
s === 'blocked' ? ['BLOCKED', 'blocked'] : s === 'needsInput' ? ['NEEDS INPUT', 'blocked'] : ['FAIL', ''];
|
|
3047
3221
|
const FINISHED = (s) => ['done', 'partial', 'failed', 'interrupted'].includes(s);
|
|
@@ -3196,16 +3370,6 @@ function reviewCardConfigFor(projectId){ return { ...DEFAULT_REVIEW_DEFINITION.r
|
|
|
3196
3370
|
// goal's own request language, same as the planner/worker; ja/en pin the
|
|
3197
3371
|
// project to one language regardless of what a given goal was typed in.
|
|
3198
3372
|
function reviewLangFor(projectId){ const l = reviewDefinitionFor(projectId).language; return (l === 'en' || l === 'ja') ? l : 'auto'; }
|
|
3199
|
-
// PRD §6.5 fold note language (Masa 2026-07-17: "ユーザーの言語に合わせて" — not
|
|
3200
|
-
// hardcoded English). Mirrors the server's own 'auto' resolution exactly
|
|
3201
|
-
// (reviewDefinition.language === 'auto' ? detectRequestLanguage(goal.text) : ...,
|
|
3202
|
-
// engine/server.mjs) so this note reads in the same language as the review
|
|
3203
|
-
// headline/PR body for the same goal: the project's fixed override wins if set,
|
|
3204
|
-
// otherwise match the language the goal's own request was written in.
|
|
3205
|
-
function foldLangFor(goal){
|
|
3206
|
-
const l = reviewLangFor(state.active);
|
|
3207
|
-
return (l === 'ja' || l === 'en') ? l : (/[-ヿ㐀-鿿]/.test(goal?.text || '') ? 'ja' : 'en');
|
|
3208
|
-
}
|
|
3209
3373
|
|
|
3210
3374
|
function esc(s){ const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
|
|
3211
3375
|
|
|
@@ -3351,32 +3515,34 @@ function buildReviewDigest({ goals, tasks } = {}){
|
|
|
3351
3515
|
// stays only as a last resort for a goal with no text. Pulled into one place so the
|
|
3352
3516
|
// review card's own title and its sibling-goal titles (H23 split note) stay consistent.
|
|
3353
3517
|
function saReviewTitle(g){
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
//
|
|
3357
|
-
//
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
//
|
|
3361
|
-
//
|
|
3362
|
-
//
|
|
3363
|
-
//
|
|
3364
|
-
|
|
3365
|
-
|
|
3518
|
+
// The goal's short ticket NAME wins everywhere it's shown (See-all title, Review-list
|
|
3519
|
+
// rail, board, sibling list) so the title reads the same in every place and never
|
|
3520
|
+
// repeats the full request (Masa 2026-07-21, spec §4). Real goals have no
|
|
3521
|
+
// reviewSummary.title, so they fall back to the request text unchanged (no regression).
|
|
3522
|
+
return String(g?.reviewSummary?.title || g?.text || g?.plan?.[0] || g?.reviewSummary?.changed || '').replace(/\s+/g, ' ').trim().slice(0, 90);
|
|
3523
|
+
}
|
|
3524
|
+
// H23 (2026-07-18) evidence-kind detection. flagship's demo data carries an explicit
|
|
3525
|
+
// `kind` field; real goals/tasks have no such field, so the review card derives one
|
|
3526
|
+
// from real signals it already has (a captured proof image, whether the changed files
|
|
3527
|
+
// are all docs-only).
|
|
3528
|
+
//
|
|
3529
|
+
// P1 (Masa 2026-07-21): the 'missing' kind is gone, and with it the app-local copy of
|
|
3530
|
+
// isUiChange(). It said "this touched the UI, so a screenshot SHOULD exist" — a rule
|
|
3531
|
+
// from the era when we photographed the work ourselves. That capture never ran outside
|
|
3532
|
+
// this one repo, so the only thing the rule ever produced was an apology on every UI
|
|
3533
|
+
// goal. We don't take the photo any more, so there is nothing to apologise for.
|
|
3366
3534
|
const DOC_ONLY_RE_LOCAL = /(^|\/)(README|START-HERE|CHANGELOG|LICENSE)(\.[\w.-]+)?$|(^|\/)docs\/.*\.(md|mdx|txt)$|\.mdx?$|\.txt$/i;
|
|
3367
3535
|
function hasTestRelevantChangesLocal(files){
|
|
3368
3536
|
const fs = (files ?? []).map((f) => String(f ?? '').trim()).filter(Boolean);
|
|
3369
3537
|
if (!fs.length) return true;
|
|
3370
3538
|
return fs.some((f) => !DOC_ONLY_RE_LOCAL.test(f));
|
|
3371
3539
|
}
|
|
3372
|
-
// Priority: image (a real capture exists) >
|
|
3373
|
-
// capture per PRD §4(C) but doesn't) > docs (every changed file is docs-only) >
|
|
3540
|
+
// Priority: image (a real capture exists) > docs (every changed file is docs-only) >
|
|
3374
3541
|
// logic (verified only by tests, no UI). Falls back to 'image' (today's generic
|
|
3375
3542
|
// fact-row + no image) when no signal is conclusive — never claims a stronger kind
|
|
3376
3543
|
// than the data actually supports.
|
|
3377
3544
|
function saEvidenceKind(filePaths, hasProofImg, testResult){
|
|
3378
3545
|
if (hasProofImg) return 'image';
|
|
3379
|
-
if (isUiChangeLocal(filePaths)) return 'missing';
|
|
3380
3546
|
if (filePaths.length && !hasTestRelevantChangesLocal(filePaths)) return 'docs';
|
|
3381
3547
|
if (testResult?.ran) return 'logic';
|
|
3382
3548
|
return 'image';
|
|
@@ -3427,11 +3593,28 @@ function requirementSummaryLine(task){
|
|
|
3427
3593
|
// Worker reports are written in Markdown (## headings, **bold**, - lists, `code`).
|
|
3428
3594
|
// The review cards showed them raw, so "## " and "**" leaked as literal text.
|
|
3429
3595
|
// Render the common subset to HTML (escaping first) so they read cleanly.
|
|
3430
|
-
|
|
3596
|
+
// P1 (Masa 2026-07-21): the AI's report IS the deliverable, so an image it pasted
|
|
3597
|
+
// (``) has to render as that image — this is the whole "paste it and it
|
|
3598
|
+
// shows up" wiring. Only src values we can vouch for are allowed through: http(s),
|
|
3599
|
+
// a same-origin path, or an inline data:image. Anything else (javascript:, data:text
|
|
3600
|
+
// /html, …) renders as the literal text it was, never as an <img src>.
|
|
3601
|
+
const MD_IMG_SRC_OK = /^(?:https?:\/\/|\/(?!\/)|data:image\/(?:png|jpe?g|gif|webp|svg\+xml);base64,)/i;
|
|
3602
|
+
// `images: false` renders a pasted image as nothing at all. Masa 2026-07-22, after
|
|
3603
|
+
// seeing one in a real card: the review card is not a gallery — the way to see the work
|
|
3604
|
+
// is Open it, and a lone blurry still of "one of the three" is worse than no still. The
|
|
3605
|
+
// renderer keeps the capability (chat and the older checklist still use images); the
|
|
3606
|
+
// review card is simply not the place.
|
|
3607
|
+
function mdLite(src, { images = true } = {}){
|
|
3431
3608
|
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
3432
3609
|
const inline = (s) => esc(s)
|
|
3433
3610
|
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
3434
3611
|
.replace(/\*\*([^*]+?)\*\*/g, '<strong>$1</strong>')
|
|
3612
|
+
// images before links —  would otherwise match the link rule below
|
|
3613
|
+
// and render as a bare "!" plus an <a>.
|
|
3614
|
+
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (m, alt, url) => (!images ? ''
|
|
3615
|
+
: MD_IMG_SRC_OK.test(url)
|
|
3616
|
+
? `<img class="mdimg" src="${url.replace(/"/g, '"')}" alt="${alt}" loading="lazy">`
|
|
3617
|
+
: m))
|
|
3435
3618
|
// Feedback: make links tappable — markdown [text](url) and bare URLs (e.g. a
|
|
3436
3619
|
// "open the ReadMe/PROOF.md" link in the worker report).
|
|
3437
3620
|
.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
|
@@ -3571,106 +3754,15 @@ function proofMediaHtml(taskId, proof, altLabel){
|
|
|
3571
3754
|
}
|
|
3572
3755
|
return imgTag;
|
|
3573
3756
|
}
|
|
3574
|
-
//
|
|
3575
|
-
//
|
|
3576
|
-
//
|
|
3577
|
-
//
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
return `<div class="beforeafter">
|
|
3584
|
-
<div class="ba-col"><span class="balabel">Before</span>${beforeTag}</div>
|
|
3585
|
-
<div class="ba-col"><span class="balabel">After</span>${proofMediaHtml(taskId, proof, altLabel)}</div>
|
|
3586
|
-
</div>`;
|
|
3587
|
-
}
|
|
3588
|
-
// Diff box + zoomed inset (HANDOFF-review-card-diffbox-2026-07-18): on top of the
|
|
3589
|
-
// plain before/after pair above, isolate the region that actually changed and draw
|
|
3590
|
-
// a red box over it on both images, plus a cropped/enlarged row below. Computed
|
|
3591
|
-
// client-side from the two PNGs via <canvas> pixel comparison at render time — not
|
|
3592
|
-
// baked in at capture time — so it works retroactively on every proof pair already
|
|
3593
|
-
// on disk with no new server-side image dependency (see the handoff for the full
|
|
3594
|
-
// rationale). Reference algorithm, verified against a real before/after pair
|
|
3595
|
-
// (composer icon swap, 640x200): correctly isolated a 57x52px box centered on the
|
|
3596
|
-
// changed icon — 0.13% image coverage, no false positives elsewhere in the frame.
|
|
3597
|
-
function computeDiffBox(beforeImg, afterImg, { threshold = 24, minCoverage = 0.0005, maxCoverage = 0.7, pad = 0.03 } = {}) {
|
|
3598
|
-
const w = beforeImg.naturalWidth, h = beforeImg.naturalHeight;
|
|
3599
|
-
if (w !== afterImg.naturalWidth || h !== afterImg.naturalHeight) return null; // different-shaped images: don't guess
|
|
3600
|
-
const c1 = document.createElement('canvas'); c1.width = w; c1.height = h;
|
|
3601
|
-
const c2 = document.createElement('canvas'); c2.width = w; c2.height = h;
|
|
3602
|
-
c1.getContext('2d').drawImage(beforeImg, 0, 0, w, h);
|
|
3603
|
-
c2.getContext('2d').drawImage(afterImg, 0, 0, w, h);
|
|
3604
|
-
const d1 = c1.getContext('2d').getImageData(0, 0, w, h).data;
|
|
3605
|
-
const d2 = c2.getContext('2d').getImageData(0, 0, w, h).data;
|
|
3606
|
-
let minX = w, minY = h, maxX = -1, maxY = -1, diffPx = 0;
|
|
3607
|
-
for (let y = 0; y < h; y++) {
|
|
3608
|
-
for (let x = 0; x < w; x++) {
|
|
3609
|
-
const i = (y * w + x) * 4;
|
|
3610
|
-
const d = Math.abs(d1[i] - d2[i]) + Math.abs(d1[i + 1] - d2[i + 1]) + Math.abs(d1[i + 2] - d2[i + 2]);
|
|
3611
|
-
if (d > threshold) {
|
|
3612
|
-
diffPx++;
|
|
3613
|
-
if (x < minX) minX = x; if (x > maxX) maxX = x;
|
|
3614
|
-
if (y < minY) minY = y; if (y > maxY) maxY = y;
|
|
3615
|
-
}
|
|
3616
|
-
}
|
|
3617
|
-
}
|
|
3618
|
-
const total = w * h;
|
|
3619
|
-
if (maxX < 0 || diffPx / total < minCoverage) return null; // no real difference — don't box a no-op
|
|
3620
|
-
if (diffPx / total > maxCoverage) return null; // diff too diffuse to be "a spot" — a full redesign shouldn't get a red box either
|
|
3621
|
-
const padPx = Math.round(Math.max(w, h) * pad);
|
|
3622
|
-
minX = Math.max(0, minX - padPx); minY = Math.max(0, minY - padPx);
|
|
3623
|
-
maxX = Math.min(w, maxX + padPx); maxY = Math.min(h, maxY + padPx);
|
|
3624
|
-
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
|
3625
|
-
}
|
|
3626
|
-
function baLoadImage(src){
|
|
3627
|
-
return new Promise((resolve, reject) => {
|
|
3628
|
-
const im = new Image();
|
|
3629
|
-
im.onload = () => resolve(im);
|
|
3630
|
-
im.onerror = () => reject(new Error(`image failed to load: ${src}`));
|
|
3631
|
-
im.src = src;
|
|
3632
|
-
});
|
|
3633
|
-
}
|
|
3634
|
-
// Crop {x,y,w,h} out of a decoded image and scale it up into a small inset — same
|
|
3635
|
-
// aspect ratio as the box, never stretched. Magnification is capped at 6x so a
|
|
3636
|
-
// near-single-pixel box doesn't blow up into mush.
|
|
3637
|
-
function baZoomDataUrl(img, box, maxW = 420, maxH = 200){
|
|
3638
|
-
const scale = Math.min(maxW / box.w, maxH / box.h, 6);
|
|
3639
|
-
const outW = Math.max(1, Math.round(box.w * scale));
|
|
3640
|
-
const outH = Math.max(1, Math.round(box.h * scale));
|
|
3641
|
-
const c = document.createElement('canvas'); c.width = outW; c.height = outH;
|
|
3642
|
-
c.getContext('2d').drawImage(img, box.x, box.y, box.w, box.h, 0, 0, outW, outH);
|
|
3643
|
-
return c.toDataURL('image/png');
|
|
3644
|
-
}
|
|
3645
|
-
// Async: loads the before/after PNGs, runs computeDiffBox(), and — only when a real
|
|
3646
|
-
// box is found — renders the before/after pair with a red box overlay on each image
|
|
3647
|
-
// (percent-positioned so it tracks the image's displayed size at any viewport width)
|
|
3648
|
-
// plus a "Zoomed on the boxed area" row below. Returns null on every fallback
|
|
3649
|
-
// condition (no PNG pair, load failure, no clean box) so the caller keeps showing
|
|
3650
|
-
// the plain beforeAfterMediaHtml() pair unchanged — never a guessed or empty box
|
|
3651
|
-
// (HANDOFF-review-card-diffbox-2026-07-18). Only kind==='image' Ledger evidence
|
|
3652
|
-
// (saEvidenceHtml) calls this; the older per-task renderReviewChecklist before/after
|
|
3653
|
-
// is explicitly out of scope for this handoff and stays on beforeAfterMediaHtml.
|
|
3654
|
-
async function beforeAfterBoxedHtml(taskId, proof, altLabel){
|
|
3655
|
-
const beforeSrc = proof?.beforePng, afterSrc = proof?.png;
|
|
3656
|
-
if (!beforeSrc || !afterSrc) return null; // the box needs two real stills, not a GIF frame
|
|
3657
|
-
let beforeImg, afterImg;
|
|
3658
|
-
try {
|
|
3659
|
-
[beforeImg, afterImg] = await Promise.all([
|
|
3660
|
-
baLoadImage(withKey(`/proof/${taskId}/${beforeSrc}`)),
|
|
3661
|
-
baLoadImage(withKey(`/proof/${taskId}/${afterSrc}`)),
|
|
3662
|
-
]);
|
|
3663
|
-
} catch { return null; } // a missing/broken file is a fallback, not a crash
|
|
3664
|
-
const box = computeDiffBox(beforeImg, afterImg);
|
|
3665
|
-
if (!box) return null;
|
|
3666
|
-
const w = beforeImg.naturalWidth, h = beforeImg.naturalHeight;
|
|
3667
|
-
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)}%`;
|
|
3668
|
-
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>`;
|
|
3669
|
-
const zoomCol = (dataUrl, label) => `<div class="ba-col"><img class="ba-zoomimg" src="${dataUrl}" alt="zoomed ${label.toLowerCase()}: ${altLabel}"></div>`;
|
|
3670
|
-
return `<div class="beforeafter">${frame(beforeImg, 'Before')}${frame(afterImg, 'After')}</div>
|
|
3671
|
-
<div class="lg-evhd ba-zoomhd">Zoomed on the boxed area</div>
|
|
3672
|
-
<div class="beforeafter ba-zoomrow">${zoomCol(baZoomDataUrl(beforeImg, box), 'before')}${zoomCol(baZoomDataUrl(afterImg, box), 'after')}</div>`;
|
|
3673
|
-
}
|
|
3757
|
+
// (P1, Masa 2026-07-21) beforeAfterMediaHtml() was deleted here. The pair was staged by
|
|
3758
|
+
// us, not by the work: the "before" shot came from the same photo-staging pass that P1
|
|
3759
|
+
// removed on the engine side (#228), and in practice it never existed at all, so every
|
|
3760
|
+
// card fell through to the single shot anyway. Callers now ask for that shot directly.
|
|
3761
|
+
// (P1, Masa 2026-07-21) The before/after diff-box machinery (computeDiffBox,
|
|
3762
|
+
// baLoadImage, baZoomDataUrl, beforeAfterBoxedHtml) was deleted here. It pixel-diffed a
|
|
3763
|
+
// "before" capture against an "after" one to draw a red box around what changed — real
|
|
3764
|
+
// craft, built on a pair that the engine no longer stages (#228) and that never existed
|
|
3765
|
+
// outside this repo anyway. With no before shot there is nothing to compare.
|
|
3674
3766
|
// swap the still for the real video only on demand, one at a time
|
|
3675
3767
|
document.addEventListener('click', (e) => {
|
|
3676
3768
|
const w = e.target.closest('.proofwrap');
|
|
@@ -3735,15 +3827,28 @@ function progressMeta(t){
|
|
|
3735
3827
|
// checked + struck through, the in-progress one is highlighted, pending ones
|
|
3736
3828
|
// are dimmed. Empty list → nothing (workers without todos look unchanged).
|
|
3737
3829
|
const TODO_MARK = { completed: '✓', in_progress: '▸', pending: '○' };
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3830
|
+
// P0b (Masa 2026-07-21, planner demoted): the AI's own TodoWrite list is now the
|
|
3831
|
+
// only breakdown a goal has (1 goal = 1 task, no invented sub-tasks). `state.todos`
|
|
3832
|
+
// mirrors it live via the 'todos' SSE event; once the task finishes, the server
|
|
3833
|
+
// freezes the same shape into `task.todosFinal` (engine/server.mjs) since the live
|
|
3834
|
+
// field gets stripped off finished tasks. This is the one place that resolves
|
|
3835
|
+
// "which list to show" — live wins while it has content, else the frozen one.
|
|
3836
|
+
function todoSourceFor(t){
|
|
3837
|
+
const live = state.todos[t?.id];
|
|
3838
|
+
if (Array.isArray(live) && live.length) return live;
|
|
3839
|
+
return Array.isArray(t?.todosFinal) ? t.todosFinal : [];
|
|
3840
|
+
}
|
|
3841
|
+
function todoRowsHtml(list){
|
|
3842
|
+
return list.map((d) => {
|
|
3742
3843
|
const st = ['completed', 'in_progress', 'pending'].includes(d.status) ? d.status : 'pending';
|
|
3743
3844
|
const label = st === 'in_progress' && d.activeForm ? d.activeForm : d.content;
|
|
3744
3845
|
return `<div class="todo ${st}"><span class="box">${TODO_MARK[st]}</span><span class="tdtext">${esc(label ?? '')}</span></div>`;
|
|
3745
3846
|
}).join('');
|
|
3746
|
-
|
|
3847
|
+
}
|
|
3848
|
+
function todosHtml(t){
|
|
3849
|
+
const list = todoSourceFor(t);
|
|
3850
|
+
if (!list.length) return '';
|
|
3851
|
+
return `<div class="todos"><div class="tdhead">To-do</div>${todoRowsHtml(list)}</div>`;
|
|
3747
3852
|
}
|
|
3748
3853
|
|
|
3749
3854
|
function taskBlock(t){
|
|
@@ -4217,7 +4322,6 @@ function renderTasks(){
|
|
|
4217
4322
|
const digestRows = buildReviewDigest({ goals: pgoals, tasks: list });
|
|
4218
4323
|
const digestCount = digestRows.length;
|
|
4219
4324
|
const dGreen = digestRows.filter((r) => !r.testResult || r.testResult.ok !== false).length;
|
|
4220
|
-
const dProof = digestRows.filter((r) => r.proof).length;
|
|
4221
4325
|
const dPct = digestCount ? Math.round((dGreen / digestCount) * 100) : 0;
|
|
4222
4326
|
const dBlocked = pgoals.filter((g) => g.status === 'blocked').length; // §10: reflect real blocked count, don't hardcode
|
|
4223
4327
|
// CDO review 2026-07-08 §6: dGreen/digestCount already exclude blocked goals (they
|
|
@@ -4235,7 +4339,7 @@ function renderTasks(){
|
|
|
4235
4339
|
? `<div class="s5sum" id="summaryEntry" title="Open the review summary (ペライチ)">
|
|
4236
4340
|
<span class="s5exp"><svg width="13" height="13" viewBox="0 0 24 24"><path d="M15 4h5v5M20 4l-7 7M9 20H4v-5M4 20l7-7"/></svg></span>
|
|
4237
4341
|
<span class="s5ring" style="background:conic-gradient(var(--green) 0 ${dPct}%, var(--hair2) ${dPct}% 100%)"><span class="s5in">${dGreen}/${digestCount}</span></span>
|
|
4238
|
-
<span class="s5tx"><span class="s5hl">${dAllClear ? 'Ready to review' : `${dGreen} of ${digestCount} ready`}</span><span class="s5sub">${dAllClear ? 'all green' : dGreen === digestCount ? 'blocked needs attention' : 'some failing'} · ${
|
|
4342
|
+
<span class="s5tx"><span class="s5hl">${dAllClear ? 'Ready to review' : `${dGreen} of ${digestCount} ready`}</span><span class="s5sub">${dAllClear ? 'all green' : dGreen === digestCount ? 'blocked needs attention' : 'some failing'} · ${dBlocked ? `${dBlocked} blocked` : 'none blocked'}</span></span>
|
|
4239
4343
|
</div>`
|
|
4240
4344
|
: '';
|
|
4241
4345
|
const REVIEW_LIMIT = 5;
|
|
@@ -4249,16 +4353,12 @@ function renderTasks(){
|
|
|
4249
4353
|
// Feedback 3: the row carries only the check-line + PR link. Tests/proof are
|
|
4250
4354
|
// internal gate checks (they still block), NOT list clutter; the Review number
|
|
4251
4355
|
// is redundant with the title; tapping the row opens the detail (no button).
|
|
4252
|
-
// PRD §6.5
|
|
4253
|
-
//
|
|
4254
|
-
//
|
|
4255
|
-
//
|
|
4256
|
-
|
|
4257
|
-
const carryNote = carried.length ? (foldLangFor(g) === 'ja'
|
|
4258
|
-
? `+ ${carried.map((x) => `#${x.id}`).join(', ')} の仕事もここに入っています`
|
|
4259
|
-
: `also carries ${carried.map((x) => `#${x.id}`).join(', ')}`) : '';
|
|
4356
|
+
// PRD §6.5 → Masa 2026-07-21: goals folded onto this one are NOT announced here
|
|
4357
|
+
// any more ("+ #901 の仕事もここに入っています" read as the same report-in-the-list
|
|
4358
|
+
// clutter as the old "→ #12 にまとめました" row — Masa: review欄にいらない). Where
|
|
4359
|
+
// the work went is logged inside the opened detail's "Also carries" section
|
|
4360
|
+
// (see saItemHtml's carriesBody) instead — the list row stays just the ask.
|
|
4260
4361
|
return `<div class="task revrow" data-goal="${g.id}"><div class="ic pending"></div><div class="tx"><div class="name">${esc(checkLine)} ${goalSourceBadge(g.source)}</div>
|
|
4261
|
-
${carryNote ? `<div class="cond carries">${carryNote}</div>` : ''}
|
|
4262
4362
|
<div class="cond">${g.pr ? `<a class="prbig" href="${esc(g.pr)}" target="_blank">PR #${esc((g.pr.match(/pull\/(\d+)/) ?? [,''])[1])} ↗</a>` : ''}</div>
|
|
4263
4363
|
<div class="qreplyrow" data-qreplyrow="${g.id}"${state.qreplyOpen.has(g.id) ? '' : ' hidden'}><input type="text" data-qreplyinput="${g.id}" placeholder="直したい点を書いて送信(Todoに戻して再作業)"><button type="button" class="qreplysend" data-qreplysend="${g.id}" aria-label="Send">${ICONS.up}</button></div></div>
|
|
4264
4364
|
<div class="revquick">
|
|
@@ -4268,16 +4368,10 @@ function renderTasks(){
|
|
|
4268
4368
|
</div></div>`;
|
|
4269
4369
|
}).join('')
|
|
4270
4370
|
+ (reviewGoalsAll.length > REVIEW_LIMIT ? `<div class="more" data-reviewmore="1">${state.showAllReview ? 'show less' : `more · +${reviewGoalsAll.length - REVIEW_LIMIT}`}</div>` : '');
|
|
4271
|
-
// PRD §3.5: a folded goal
|
|
4272
|
-
//
|
|
4273
|
-
//
|
|
4274
|
-
//
|
|
4275
|
-
const foldedRows = pgoals.filter((g) => g.status === 'folded').map((g) => {
|
|
4276
|
-
const checkLine = (g.reviewSummary?.check || g.plan?.[0] || g.text || '').replace(/\s+/g, ' ').slice(0, 66);
|
|
4277
|
-
const mergedNote = foldLangFor(g) === 'ja' ? `→ #${esc(String(g.foldedInto))} にまとめました` : `merged into #${esc(String(g.foldedInto))}`;
|
|
4278
|
-
return `<div class="task revrow foldrow" data-goal="${g.id}"><div class="ic pending"></div><div class="tx"><div class="name">${esc(checkLine)} ${goalSourceBadge(g.source)}</div>
|
|
4279
|
-
<div class="cond">${mergedNote}</div></div></div>`;
|
|
4280
|
-
}).join('');
|
|
4371
|
+
// PRD §3.5 → Masa 2026-07-21: a folded goal no longer gets its own "→ #12 にまとめました"
|
|
4372
|
+
// row in the Review column (Masa: review欄にいらない・わかりにくい). It is never deleted —
|
|
4373
|
+
// where its work went is now logged inside the carrier review's detail ("Also carries",
|
|
4374
|
+
// see saItemHtml), so the column stays clean while the trail is still one tap away.
|
|
4281
4375
|
const doneRows = grouped(list.filter((t) => ['done', 'skipped'].includes(t.status)).sort((a, b) => b.num - a.num), (t) => row(t));
|
|
4282
4376
|
// Pending = deliberately shelved goals (e.g. "npm公開はPhase 2"): never
|
|
4283
4377
|
// planned or queued until Start is pressed. Parked below To do on purpose.
|
|
@@ -4289,7 +4383,7 @@ function renderTasks(){
|
|
|
4289
4383
|
const savedQAns = captureFocusedAnswerInput($('tasklist'), 'data-qansinput');
|
|
4290
4384
|
$('tasklist').innerHTML =
|
|
4291
4385
|
sec('Attention', attention, 'attn')
|
|
4292
|
-
+ sec(pickColumnLabel(cols, 'review', 'Review'), reviewDigest + review
|
|
4386
|
+
+ sec(pickColumnLabel(cols, 'review', 'Review'), reviewDigest + review, 'reviewsec', reviewDefinitionFor(state.active).description, REVIEW_DEF_HINT)
|
|
4293
4387
|
+ sec(pickColumnLabel(cols, 'doing', 'Doing'), doing)
|
|
4294
4388
|
+ sec(pickColumnLabel(cols, 'todo', 'To do'), todo, 'todosec')
|
|
4295
4389
|
+ sec('Pending', pendingRows, 'dim')
|
|
@@ -4370,7 +4464,7 @@ function renderTasks(){
|
|
|
4370
4464
|
// no auto-rework. Rework is asked for via chat.
|
|
4371
4465
|
b.onclick = async (e) => { e.stopPropagation();
|
|
4372
4466
|
let r; for (const id of b.dataset.qdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/reject`), { method: 'POST' });
|
|
4373
|
-
qrefresh(r, '
|
|
4467
|
+
qrefresh(r, 'Dismiss failed.'); };
|
|
4374
4468
|
}
|
|
4375
4469
|
// Archive: retire an unfinished goal cleanly (no rework spawned). The escape
|
|
4376
4470
|
// hatch for half-finished (partial/blocked) items so Attention stays drainable.
|
|
@@ -4741,9 +4835,6 @@ function renderRdEditor(){
|
|
|
4741
4835
|
<label class="rdrow"><input type="checkbox" id="rdCardScreenshots"${state.rdDraft.reviewCard.screenshots ? ' checked' : ''}>
|
|
4742
4836
|
<span><span class="rdlabel">スクショ / Screenshots</span>
|
|
4743
4837
|
<span class="rdhint">レビューカードに証跡(スクショ/GIF)を表示する。</span></span></label>
|
|
4744
|
-
<label class="rdrow"><input type="checkbox" id="rdCardBeforeAfter"${state.rdDraft.reviewCard.beforeAfter ? ' checked' : ''}>
|
|
4745
|
-
<span><span class="rdlabel">Before/After</span>
|
|
4746
|
-
<span class="rdhint">変更前後を並べて表示する(変更前キャプチャが無い場合は通常表示になります)。</span></span></label>
|
|
4747
4838
|
|
|
4748
4839
|
<div class="rdsub">出力言語 / Output language</div>
|
|
4749
4840
|
<label class="rdrow"><select id="rdLanguage">
|
|
@@ -4764,7 +4855,6 @@ function renderRdEditor(){
|
|
|
4764
4855
|
$('rdPrScreenshots').addEventListener('change', (e) => { state.rdDraft.prSections.screenshots = e.target.checked; });
|
|
4765
4856
|
$('rdPrRisk').addEventListener('change', (e) => { state.rdDraft.prSections.risk = e.target.checked; });
|
|
4766
4857
|
$('rdCardScreenshots').addEventListener('change', (e) => { state.rdDraft.reviewCard.screenshots = e.target.checked; });
|
|
4767
|
-
$('rdCardBeforeAfter').addEventListener('change', (e) => { state.rdDraft.reviewCard.beforeAfter = e.target.checked; });
|
|
4768
4858
|
$('rdLanguage').addEventListener('change', (e) => { state.rdDraft.language = e.target.value; });
|
|
4769
4859
|
}
|
|
4770
4860
|
function renderWfEditor(){
|
|
@@ -4958,10 +5048,8 @@ function renderReviewChecklist(){
|
|
|
4958
5048
|
$('reviewDoneMsg').classList.toggle('show', goal.status === 'done');
|
|
4959
5049
|
// task 97: doneカードの『元に戻す』——押し間違いのApproveを取り消し、reviewへ戻す。
|
|
4960
5050
|
$('reviewUndoBtn').onclick = () => revertDoneGoal(goal);
|
|
4961
|
-
// reviewCard.screenshots off → the media block
|
|
4962
|
-
//
|
|
4963
|
-
// on → try the before/after comparison (falls back to the single shot when
|
|
4964
|
-
// there's no "before" capture — see beforeAfterMediaHtml).
|
|
5051
|
+
// reviewCard.screenshots off → the media block is skipped entirely, not just
|
|
5052
|
+
// emptied. (The Before/After switch is gone with the staged pair it controlled — P1.)
|
|
4965
5053
|
const rc = reviewCardConfigFor(state.active);
|
|
4966
5054
|
$('reviewList').innerHTML = reqs.map((t, i) => {
|
|
4967
5055
|
const st = approvals[t.id];
|
|
@@ -4972,13 +5060,14 @@ function renderReviewChecklist(){
|
|
|
4972
5060
|
<div class="reqhead"><span class="tick">✓</span><span class="reqnum">${t.num ?? i + 1}</span><span class="reqtitle">${esc(t.title ?? '')}</span><span class="apvd">Approved · tap to reopen</span></div>
|
|
4973
5061
|
</div>`;
|
|
4974
5062
|
}
|
|
4975
|
-
|
|
4976
|
-
|
|
5063
|
+
// P1: one shot when a promised check actually captured one, otherwise nothing at
|
|
5064
|
+
// all — "No capture yet" was us apologising for a photo we no longer go and take.
|
|
5065
|
+
const media = proofMediaHtml(t.id, t.proof, esc(t.title ?? ''));
|
|
4977
5066
|
// task 156/234 + 160: show the WORKER'S REPORT (what it actually did/found,
|
|
4978
5067
|
// incl. any test-only verification it describes) — that's what you read to
|
|
4979
5068
|
// decide. Falls back to the instruction if the worker left no result.
|
|
4980
5069
|
const report = String(t.result ?? '').replace(/^検証(PASS|FAIL)[^\n]*\n+/, '').replace(/\s+$/, '').trim();
|
|
4981
|
-
const desc = report ? `<div class="reqdesc"><span class="reqreportlbl">Worker report</span>${mdLite(report
|
|
5070
|
+
const desc = report ? `<div class="reqdesc"><span class="reqreportlbl">Worker report</span>${mdLite(report)}</div>`
|
|
4982
5071
|
: t.detail ? `<div class="reqdesc">${mdLite(t.detail.slice(0, 600))}</div>` : '';
|
|
4983
5072
|
const usage = formatUsageCompact(t.usage);
|
|
4984
5073
|
const usageBadge = usage ? `<span class="tbadge usage">${usage} tokens</span>` : '';
|
|
@@ -5290,7 +5379,7 @@ async function sendReviewReject(){
|
|
|
5290
5379
|
upsert(state.goals, body.goal);
|
|
5291
5380
|
closeReviewChecklist();
|
|
5292
5381
|
render();
|
|
5293
|
-
} catch { showErr('
|
|
5382
|
+
} catch { showErr('Dismiss failed.'); }
|
|
5294
5383
|
}
|
|
5295
5384
|
$('reviewDismiss').onclick = () => sendReviewReject();
|
|
5296
5385
|
$('reviewFixSend').onclick = () => { const t = $('reviewFixInput').value.trim(); if (t || reviewAttach.length) sendReviewDismiss(t); };
|
|
@@ -5490,7 +5579,7 @@ async function sendReject(goalId){
|
|
|
5490
5579
|
upsert(state.goals, body.goal);
|
|
5491
5580
|
closeGoalDetail();
|
|
5492
5581
|
render();
|
|
5493
|
-
} catch { showErr('
|
|
5582
|
+
} catch { showErr('Dismiss failed.'); }
|
|
5494
5583
|
$('gdApprove').disabled = false; $('gdDismiss').disabled = false; $('gdSend').disabled = false;
|
|
5495
5584
|
}
|
|
5496
5585
|
async function sendDismiss(goalId, text){
|
|
@@ -5924,6 +6013,11 @@ function renderFsBoard(){
|
|
|
5924
6013
|
const paused = !!state.projects.find((p) => p.id === state.active)?.paused; // Doing列トグルの状態
|
|
5925
6014
|
const { pgoals, list } = fsBucketTodo();
|
|
5926
6015
|
const tt = (s, n) => esc(String(s ?? '').replace(/\s+/g, ' ').slice(0, n ?? 60));
|
|
6016
|
+
// Card copy language — same rule as reviewLangFor(state.active) === 'ja', inlined
|
|
6017
|
+
// (not calling reviewLangFor itself) because engine/test/board-goal-level.test.mjs
|
|
6018
|
+
// evals this function body standalone off a minimal `state` stub with no
|
|
6019
|
+
// state.reviewDefinitions; reviewLangFor would throw there.
|
|
6020
|
+
const boardJa = state.reviewDefinitions?.[state.active]?.language === 'ja';
|
|
5927
6021
|
const goalNo = new Map();
|
|
5928
6022
|
for (const g of pgoals) { const n = goalReviewNumber({ goal: g, tasks: list }); if (n != null) goalNo.set(g.id, n); }
|
|
5929
6023
|
// + New in EVERY column, matching the flagship Artifact: a hover-reveal "+" in the header
|
|
@@ -5938,6 +6032,35 @@ function renderFsBoard(){
|
|
|
5938
6032
|
// status, so the card never repeats it. Stopped/failed carry NO bar (a dead bar would claim
|
|
5939
6033
|
// the goal is moving) — a coloured word (Failed/Interrupted) says it instead.
|
|
5940
6034
|
const goalTasks = (g) => list.filter((t) => t.goalId === g.id && !t.reply);
|
|
6035
|
+
// P0b (Masa 2026-07-21): the card's breakdown is the AI's OWN TodoWrite list — never
|
|
6036
|
+
// the planner's invented sub-tasks (there aren't any anymore; 1 goal = 1 task). Live
|
|
6037
|
+
// list wins while it has content, else the frozen task.todosFinal (mirrors the
|
|
6038
|
+
// module-level todoSourceFor() used by the chat feed's todosHtml — duplicated inline
|
|
6039
|
+
// rather than called, same reason as boardJa above: this whole function is evaluated
|
|
6040
|
+
// standalone by the board-goal-level test harness, which only stubs `state`/`esc`).
|
|
6041
|
+
const todoListFor = (t) => {
|
|
6042
|
+
const live = state.todos?.[t?.id];
|
|
6043
|
+
if (Array.isArray(live) && live.length) return live;
|
|
6044
|
+
return Array.isArray(t?.todosFinal) ? t.todosFinal : [];
|
|
6045
|
+
};
|
|
6046
|
+
// Capped + folded rather than scrolled (§1.5-8). While the task is actively running
|
|
6047
|
+
// but hasn't reached its first TodoWrite yet, the list is genuinely empty — show one
|
|
6048
|
+
// small "preparing" line instead of an empty container (§1.5-3), never a bare gap.
|
|
6049
|
+
const TODO_CARD_CAP = 5, TODO_MARK_BOARD = { completed: '✓', in_progress: '▸', pending: '○' };
|
|
6050
|
+
const goalCardTodosHtml = (t) => {
|
|
6051
|
+
if (!t) return '';
|
|
6052
|
+
const list_ = todoListFor(t);
|
|
6053
|
+
if (!list_.length) return t.status === 'running' ? `<div class="lbc-todos prep">${boardJa ? '準備中…' : 'Preparing…'}</div>` : '';
|
|
6054
|
+
const shown = list_.slice(0, TODO_CARD_CAP);
|
|
6055
|
+
const restN = list_.length - shown.length;
|
|
6056
|
+
const rows = shown.map((d) => {
|
|
6057
|
+
const st = ['completed', 'in_progress', 'pending'].includes(d.status) ? d.status : 'pending';
|
|
6058
|
+
const label = st === 'in_progress' && d.activeForm ? d.activeForm : d.content;
|
|
6059
|
+
return `<div class="todo ${st}"><span class="box">${TODO_MARK_BOARD[st]}</span><span class="tdtext">${esc(label ?? '')}</span></div>`;
|
|
6060
|
+
}).join('');
|
|
6061
|
+
const more = restN > 0 ? `<div class="lbc-more">+${restN} more</div>` : '';
|
|
6062
|
+
return `<div class="lbc-todos">${rows}${more}</div>`;
|
|
6063
|
+
};
|
|
5941
6064
|
const goalCard = (g) => {
|
|
5942
6065
|
const bkt = fsBoardBucket(g, list);
|
|
5943
6066
|
// errored is status-based now (blocked/failed/partial/interrupted live in the Needs-you column,
|
|
@@ -5962,13 +6085,21 @@ function renderFsBoard(){
|
|
|
5962
6085
|
+ (word ? `<span class="lbc-w ${amber ? 'intr' : 'bad'}">${word}</span>` : '')
|
|
5963
6086
|
+ '</div>'
|
|
5964
6087
|
+ `<div class="lbc-t">${tt(g.text || g.plan?.[0], 90)}</div>`;
|
|
6088
|
+
// The goal's one task (post-demotion there's exactly one non-reply task; [0]
|
|
6089
|
+
// is just defensive against any pre-migration leftovers with more than one).
|
|
6090
|
+
const task = goalTasks(g)[0];
|
|
6091
|
+
// Todos: not shown for To Do / Later — nothing has actually started yet there, so
|
|
6092
|
+
// there is nothing honest to fold open (quiet stays quiet). Doing/Needs/Review/Done
|
|
6093
|
+
// all show the AI's own breakdown — that's what makes it survive past "running".
|
|
6094
|
+
if (task && bkt !== 'todo' && bkt !== 'later') h += goalCardTodosHtml(task);
|
|
5965
6095
|
// n/m is Doing-only (Masa 2026-07-17): a fraction on a stopped/queued/done card is
|
|
5966
6096
|
// residue, not progress — a card showing n/m reads unambiguously as "moving right now".
|
|
5967
|
-
// done/total
|
|
5968
|
-
|
|
5969
|
-
|
|
6097
|
+
// P0b: done/total now reflect the AI's OWN todos (not the planner's invented tasks,
|
|
6098
|
+
// which under 1-goal=1-task degenerate to a useless 0/1 → 1/1).
|
|
6099
|
+
if (healthyRun && task) {
|
|
6100
|
+
const tl = todoListFor(task), total = tl.length;
|
|
5970
6101
|
if (total) {
|
|
5971
|
-
const done =
|
|
6102
|
+
const done = tl.filter((d) => d.status === 'completed').length;
|
|
5972
6103
|
h += `<div class="lbc-meter"><span class="bar"><i style="width:${Math.round(done / total * 100)}%"></i></span><span class="frac">${done}/${total}</span></div>`;
|
|
5973
6104
|
}
|
|
5974
6105
|
}
|
|
@@ -6058,16 +6189,11 @@ function renderFsReview(){
|
|
|
6058
6189
|
const pgoals = state.goals.filter((g) => g.projectId === state.active);
|
|
6059
6190
|
const list = state.tasks.filter((t) => t.projectId === state.active);
|
|
6060
6191
|
const rows = buildReviewDigest({ goals: pgoals, tasks: list });
|
|
6061
|
-
// PRD §3.5/§6.5: folded goals
|
|
6062
|
-
//
|
|
6063
|
-
//
|
|
6064
|
-
|
|
6065
|
-
// early there would make #13 vanish for the length of the re-run — exactly the "where
|
|
6066
|
-
// did my finished work go" this feature exists to prevent.
|
|
6067
|
-
const foldedGoals = pgoals.filter((g) => g.status === 'folded');
|
|
6068
|
-
if (!rows.length && !foldedGoals.length) { el.innerHTML = ''; return; } // §1.5-3: no reviews → the UI itself does not exist
|
|
6192
|
+
// PRD §3.5/§6.5 → Masa 2026-07-21: folded goals no longer get their own card in the
|
|
6193
|
+
// Review column (Masa: review欄にいらない). They were never deleted — where their work
|
|
6194
|
+
// went is now logged inside the carrier review's detail ("Also carries", see saItemHtml).
|
|
6195
|
+
if (!rows.length) { el.innerHTML = ''; return; } // §1.5-3: no reviews → the UI itself does not exist
|
|
6069
6196
|
const green = rows.filter((r) => !r.testResult || r.testResult.ok !== false).length;
|
|
6070
|
-
const proofN = rows.filter((r) => r.proof).length;
|
|
6071
6197
|
const blockedN = pgoals.filter((g) => g.status === 'blocked').length;
|
|
6072
6198
|
const pct = rows.length ? Math.round((green / rows.length) * 100) : 0;
|
|
6073
6199
|
// CDO review 2026-07-08 §6: same fix as the s5sum digest above — green/rows.length
|
|
@@ -6075,7 +6201,7 @@ function renderFsReview(){
|
|
|
6075
6201
|
// ratio was already correct; "all green" is the line that used to fire regardless
|
|
6076
6202
|
// of blockedN, reading as "18/18 ready · all green · 1 blocked" (self-contradictory).
|
|
6077
6203
|
const allClear = green === rows.length && !blockedN;
|
|
6078
|
-
const sline = `${allClear ? 'all green' : green === rows.length ? 'blocked needs attention' : `${rows.length - green} failing`} · ${
|
|
6204
|
+
const sline = `${allClear ? 'all green' : green === rows.length ? 'blocked needs attention' : `${rows.length - green} failing`} · ${blockedN ? `${blockedN} blocked` : 'none blocked'}`;
|
|
6079
6205
|
const RCAP = 3;
|
|
6080
6206
|
const shown = fsUI.revMore ? rows : rows.slice(0, RCAP);
|
|
6081
6207
|
const card = (r) => {
|
|
@@ -6095,31 +6221,18 @@ function renderFsReview(){
|
|
|
6095
6221
|
// retest ×3 note (v45 §5.3): the goal came back green from a Ledger Dismiss
|
|
6096
6222
|
const g0 = pgoals.find((g) => g.id === r.goalIds[0]);
|
|
6097
6223
|
const rnote = g0?.retest?.passed ? `<span class="rnote">✓ ${esc(g0.retest.note)}</span>` : '';
|
|
6098
|
-
// PRD §6.5
|
|
6099
|
-
//
|
|
6100
|
-
//
|
|
6101
|
-
|
|
6102
|
-
const cnote = carried.length ? `<span class="fnote">${foldLangFor(g0) === 'ja'
|
|
6103
|
-
? `+ ${carried.map((x) => `#${x.id}`).join(', ')} の仕事もここに入っています`
|
|
6104
|
-
: `also carries ${carried.map((x) => `#${x.id}`).join(', ')}`}</span>` : '';
|
|
6224
|
+
// PRD §6.5 → Masa 2026-07-21: goals folded onto this card's PR no longer get an
|
|
6225
|
+
// inline "+ #901 の仕事もここに入っています" note here — same report-in-the-list
|
|
6226
|
+
// clutter the old standalone fold row was (Masa: review欄にいらない). It is logged
|
|
6227
|
+
// inside the opened detail's "Also carries" section instead (see saItemHtml).
|
|
6105
6228
|
// Title = the goal's own title (saReviewTitle → text-first), same as the See-all
|
|
6106
6229
|
// Ledger and the To Do list, so a review card and its list row read identically
|
|
6107
6230
|
// (Masa 2026-07-20: this card showed reviewSummary.check "…が出ない" while the list
|
|
6108
6231
|
// showed the request text). Plan-reviews keep their "PLAN — approve to execute:"
|
|
6109
6232
|
// checkLine, which is the actionable label there, not a title mismatch.
|
|
6110
|
-
return `<div class="rcard${img ? '' : ' noimg'}" data-fsrev="${r.goalIds[0]}">${img}<div class="rc"><div class="t">${rid}${esc(r.plan ? r.checkLine : saReviewTitle(g0))}</div>${rnote}${
|
|
6233
|
+
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}${pr}</div>
|
|
6111
6234
|
<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>`;
|
|
6112
6235
|
};
|
|
6113
|
-
// A folded goal is settled, not failed: no ✓/✕ (the thing to approve lives on the goal
|
|
6114
|
-
// it was folded into), no red, no painted surface (§1.5-8) — just its own number, what
|
|
6115
|
-
// it asked for, and where the work went. Tapping it opens that goal (§1.5-9 wording).
|
|
6116
|
-
const foldCard = (g) => {
|
|
6117
|
-
const line = saReviewTitle(g).slice(0, 60); // title-first, same as the review card above
|
|
6118
|
-
const mergedNote = foldLangFor(g) === 'ja' ? `→ #${esc(String(g.foldedInto))} にまとめました` : `merged into #${esc(String(g.foldedInto))}`;
|
|
6119
|
-
return `<div class="rcard noimg fold" data-fsfold="${g.id}">
|
|
6120
|
-
<div class="rc"><div class="t"><span class="rid gno" data-n="${g.id}">#${g.id}</span>${esc(line)}</div>
|
|
6121
|
-
<span class="fnote">${mergedNote}</span></div></div>`;
|
|
6122
|
-
};
|
|
6123
6236
|
// V3-C (Masa決定 2026-07-07): no collapsed strip — it duplicated the sm2 meter right
|
|
6124
6237
|
// above. Reviews render directly: meter + Review header + cards (+N more fold, §1.5-8).
|
|
6125
6238
|
// The meter counts what is reviewable, so it only exists when something is (§1.5-3):
|
|
@@ -6135,7 +6248,7 @@ function renderFsReview(){
|
|
|
6135
6248
|
<button class="rvb${fsUI.rv === 1 ? ' on' : ''}" data-fsrv="1" title="List"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="7" height="7" rx="1.5"/><path d="M13 6h8M13 9h5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><path d="M13 16h8M13 19h5"/></svg></button>
|
|
6136
6249
|
<button class="rvb${fsUI.rv === 2 ? ' on' : ''}" data-fsrv="2" title="Compact"><svg viewBox="0 0 24 24"><path d="M3 6h18M3 12h18M3 18h18"/></svg></button>
|
|
6137
6250
|
</span></div>
|
|
6138
|
-
<div class="revlist">${shown.map(card).join('')}
|
|
6251
|
+
<div class="revlist">${shown.map(card).join('')}
|
|
6139
6252
|
${rows.length > RCAP ? `<div class="morefold" id="fsRevMore">${fsUI.revMore ? 'less' : `+${rows.length - RCAP} more`}<span class="g">›</span></div>` : ''}</div>`;
|
|
6140
6253
|
// Mirror the ready-review count onto the mobile top-bar "Review N" opener (H23).
|
|
6141
6254
|
const _hn = document.getElementById('fsHambN'); if (_hn) _hn.textContent = green > 0 ? String(green) : '';
|
|
@@ -6155,19 +6268,8 @@ function renderFsReview(){
|
|
|
6155
6268
|
if (e.target.closest('a') || e.target.closest('.act')) return;
|
|
6156
6269
|
openReviewChecklist(Number(c.dataset.fsrev));
|
|
6157
6270
|
};
|
|
6158
|
-
//
|
|
6159
|
-
//
|
|
6160
|
-
// finishes, so resolve from its live status instead of assuming (openReviewChecklist
|
|
6161
|
-
// on a running goal would present an empty checklist).
|
|
6162
|
-
for (const c of el.querySelectorAll('[data-fsfold]')) c.onclick = (e) => {
|
|
6163
|
-
if (e.target.closest('a')) return;
|
|
6164
|
-
const g = state.goals.find((x) => x.id === Number(c.dataset.fsfold));
|
|
6165
|
-
const target = state.goals.find((x) => x.id === g?.foldedInto);
|
|
6166
|
-
if (!target) return;
|
|
6167
|
-
goToGoalInChat(target.id);
|
|
6168
|
-
if (target.status === 'review') openReviewChecklist(target.id);
|
|
6169
|
-
else openGoalDetail(target.id);
|
|
6170
|
-
};
|
|
6271
|
+
// Folded goals no longer render their own card here (Masa 2026-07-21) — where their
|
|
6272
|
+
// work went is logged inside the carrier review's detail ("Also carries"), not tapped.
|
|
6171
6273
|
for (const b of el.querySelectorAll('[data-fsapprove]')) b.onclick = async (e) => { e.stopPropagation();
|
|
6172
6274
|
let ok = true, mergeGoal = null;
|
|
6173
6275
|
for (const id of b.dataset.fsapprove.split(',')) {
|
|
@@ -6181,7 +6283,7 @@ function renderFsReview(){
|
|
|
6181
6283
|
for (const b of el.querySelectorAll('[data-fsdismiss]')) b.onclick = async (e) => { e.stopPropagation();
|
|
6182
6284
|
// 07-20c: the review-card ✕ is a pure Reject → Rejected column (/reject), no auto-rework.
|
|
6183
6285
|
let r; for (const id of b.dataset.fsdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/reject`), { method: 'POST' });
|
|
6184
|
-
if (r && r.ok) await refresh(); else showErr('
|
|
6286
|
+
if (r && r.ok) await refresh(); else showErr('Dismiss failed.'); };
|
|
6185
6287
|
}
|
|
6186
6288
|
// Shared To Do bucketing (list rows AND the kanban board read the same groups —
|
|
6187
6289
|
// single source of truth, see renderFsBoard below). Pure of the DOM: state in, groups out.
|
|
@@ -6308,7 +6410,7 @@ function renderFsTodo(){
|
|
|
6308
6410
|
<div class="tl">
|
|
6309
6411
|
${(doingRows || paused) ? `<span class="tlcap tlcap-doing" style="padding-top:0">Doing<button class="tlcap-pause" data-fspause="1" aria-pressed="${paused ? 'true' : 'false'}" title="${paused ? 'Resume — 再開' : 'Pause — 一時停止'}">${paused ? '<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>' : '<svg viewBox="0 0 24 24"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg>'}</button></span>${doingRows}` : ''}
|
|
6310
6412
|
${needsRows ? `<span class="tlcap needs"${(doingRows || paused) ? '' : ' style="padding-top:0"'}>Needs you</span>${needsRows}` : ''}
|
|
6311
|
-
${rejected.length ? `<span class="tlcap rejcap"${(doingRows || paused) || needsRows ? '' : ' style="padding-top:0"'}>
|
|
6413
|
+
${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('')}` : ''}
|
|
6312
6414
|
${upRows ? `<span class="tlcap"${(doingRows || paused) || needsRows || rejected.length ? '' : ' style="padding-top:0"'}>Next up</span>${upRows}` : ''}
|
|
6313
6415
|
${queued.length > NCAP ? `<div class="morefold" id="fsTodoMore">${fsUI.todoMore ? 'less' : `+${queued.length - NCAP} more`}<span class="g">›</span></div>` : ''}
|
|
6314
6416
|
${laterRows ? `<span class="tlcap">Later</span>${laterRows}` : ''}
|
|
@@ -7244,8 +7346,14 @@ async function refresh(){
|
|
|
7244
7346
|
// back the already-accumulated activity for 'running' tasks.
|
|
7245
7347
|
for (const t of state.tasks) { if (t.activity?.length) state.act[t.id] = t.activity; }
|
|
7246
7348
|
// Same for the live to-do list: /api/state ships t.todos for running tasks
|
|
7247
|
-
// (task 108/109) so a mid-run refresh restores the checklist immediately.
|
|
7248
|
-
|
|
7349
|
+
// (task 108/109) so a mid-run refresh restores the checklist immediately. A
|
|
7350
|
+
// FINISHED task has no live t.todos (trimTaskActivityForState strips it) but
|
|
7351
|
+
// keeps t.todosFinal — seed from that too (P0b), so a reload of a completed
|
|
7352
|
+
// goal still shows the AI's own breakdown instead of losing it silently.
|
|
7353
|
+
for (const t of state.tasks) {
|
|
7354
|
+
if (t.todos?.length) state.todos[t.id] = t.todos;
|
|
7355
|
+
else if (t.todosFinal?.length) state.todos[t.id] = t.todosFinal;
|
|
7356
|
+
}
|
|
7249
7357
|
state.connectCommand = s.connectCommand ?? ''; state.models = s.models ?? ['sonnet']; state.agentModels = s.agentModels ?? null; state.agentEfforts = s.agentEfforts ?? null; state.agents = s.agents ?? ['claude-code', 'codex']; state.uiVersion = s.uiVersion;
|
|
7250
7358
|
state.billing = s.billing ?? { licensed: false, email: null };
|
|
7251
7359
|
renderAppSelect(); // picker follows the server's installed-agent set; may correct a stale connectedApp
|
|
@@ -7884,7 +7992,7 @@ function closeSummaryPera(){ $('summaryOverlay').classList.remove('show'); }
|
|
|
7884
7992
|
// 4 archived / 5 reverted — session-local; the server round-trip is what
|
|
7885
7993
|
// settles it (see saSync's "returned to review" reset).
|
|
7886
7994
|
// ============================================================================
|
|
7887
|
-
const SA = { open: false, items: [], diffs: {},
|
|
7995
|
+
const SA = { open: false, items: [], diffs: {}, detOpen: new Set(), focusGid: null, focusScrolled: false, focusTimer: null, cur: null };
|
|
7888
7996
|
window.SA = SA; // top-level const isn't a window property; external checks probe window.SA (same convention as window.state above)
|
|
7889
7997
|
const SA_SVG = {
|
|
7890
7998
|
ok: '<svg viewBox="0 0 24 24"><path d="M4 12l5 5L20 6"/></svg>',
|
|
@@ -7917,24 +8025,77 @@ function saRows(){
|
|
|
7917
8025
|
return { no: goalReviewNumber({ goal: sg, tasks: ts }), title: saReviewTitle(sg), cur: gid === r.goalIds[0] };
|
|
7918
8026
|
})
|
|
7919
8027
|
: null;
|
|
8028
|
+
// Masa 2026-07-21: goals folded onto this review no longer take their own Review-column
|
|
8029
|
+
// row ("→ #12 にまとめました" read as clutter). Instead this review's detail logs what
|
|
8030
|
+
// it carries. Mirrors engine/lib.mjs foldedIntoReview() (browser can't import it).
|
|
8031
|
+
const folded = gs.filter((x) => x.status === 'folded' && x.foldedInto != null && r.goalIds.includes(x.foldedInto))
|
|
8032
|
+
.map((x) => ({ id: x.id, title: saReviewTitle(x) }));
|
|
7920
8033
|
return {
|
|
7921
|
-
ids: r.goalIds, goalId: r.goalIds[0], goal: g,
|
|
8034
|
+
ids: r.goalIds, goalId: r.goalIds[0], goal: g, folded,
|
|
7922
8035
|
no: r.reviewNumber,
|
|
7923
8036
|
title: saReviewTitle(g),
|
|
7924
8037
|
check: String(g?.reviewSummary?.check || r.checkLine || '').replace(/\s+/g, ' ').trim(),
|
|
7925
8038
|
goalText: String(g?.text ?? '').replace(/\s+/g, ' ').trim(),
|
|
7926
8039
|
did, siblings, risk: g?.risk ?? null,
|
|
7927
8040
|
note: g?.retest?.passed ? g.retest.note : null,
|
|
7928
|
-
pr: r.pr,
|
|
8041
|
+
pr: r.pr,
|
|
8042
|
+
// P0b (Masa 2026-07-21): the AI's own TodoWrite breakdown for the "Steps" section
|
|
8043
|
+
// below — the primary goal's own non-reply task, frozen at completion. Distinct
|
|
8044
|
+
// from `did` above (task.result prose) and from Activity (raw tool-call log).
|
|
8045
|
+
todosFinal: (() => {
|
|
8046
|
+
const t = ts.find((x) => x.goalId === g?.id && !x.reply);
|
|
8047
|
+
return Array.isArray(t?.todosFinal) ? t.todosFinal : [];
|
|
8048
|
+
})(),
|
|
8049
|
+
// P1 (Masa 2026-07-21): the AI's own final report, verbatim and untrimmed — the
|
|
8050
|
+
// card renders it as Markdown and interprets nothing. This is the deliverable for
|
|
8051
|
+
// every ask that has no running thing to open ("research this", "explain that"),
|
|
8052
|
+
// and the explanation for every ask that does.
|
|
8053
|
+
report: (() => {
|
|
8054
|
+
const t = ts.find((x) => x.goalId === g?.id && !x.reply && x.result);
|
|
8055
|
+
return String(t?.result ?? '').replace(/^検証(PASS|FAIL)[^\n]*\n+/, '').trim();
|
|
8056
|
+
})(),
|
|
8057
|
+
// …and the worker's own "here's how you see it running" declaration (task.run,
|
|
8058
|
+
// written by the worker into .manager-run.json). Absent for asks with nothing to
|
|
8059
|
+
// run — then no button, which is the honest state, not a missing feature.
|
|
8060
|
+
run: (() => {
|
|
8061
|
+
const t = ts.find((x) => x.goalId === g?.id && !x.reply && x.run);
|
|
8062
|
+
return t ? { taskId: t.id, ...t.run } : null;
|
|
8063
|
+
})(),
|
|
8064
|
+
// Explicit review-summary display fields (spec §11: the card's shape follows the
|
|
8065
|
+
// task). A goal's reviewSummary may carry them directly (e.g. a conversational task
|
|
8066
|
+
// that reports an Answer + its Activity, or a ticket whose test count / diff totals
|
|
8067
|
+
// are recorded rather than re-derived); each falls back to the computed value.
|
|
8068
|
+
testResult: (typeof (g?.reviewSummary?.tests) === 'string')
|
|
8069
|
+
? { ran: true, ok: true, passed: parseInt(g.reviewSummary.tests, 10) || 0, failed: 0, label: g.reviewSummary.tests }
|
|
8070
|
+
: r.testResult,
|
|
8071
|
+
answer: g?.reviewSummary?.answer ?? null,
|
|
8072
|
+
activity: Array.isArray(g?.reviewSummary?.activity) ? g.reviewSummary.activity : null,
|
|
8073
|
+
discussion: Array.isArray(g?.reviewSummary?.discussion) ? g.reviewSummary.discussion : null,
|
|
8074
|
+
changedDesc: g?.reviewSummary?.changed ?? null,
|
|
8075
|
+
metaFiles: Array.isArray(g?.reviewSummary?.files) ? g.reviewSummary.files : null,
|
|
8076
|
+
metaAdd: g?.reviewSummary?.a ?? null, metaDel: g?.reviewSummary?.d ?? null,
|
|
8077
|
+
ghRows: Array.isArray(g?.reviewSummary?.ghdiff) ? g.reviewSummary.ghdiff : null,
|
|
8078
|
+
diffTotal: g?.reviewSummary?.diffTotal ?? null,
|
|
7929
8079
|
proofTaskId: pt?.id ?? null, proofImg: pt?.proof?.png ?? pt?.proof?.gif ?? null,
|
|
7930
|
-
//
|
|
7931
|
-
//
|
|
7932
|
-
//
|
|
7933
|
-
|
|
8080
|
+
// Recording (spec §5/§6): a proof task may carry a screen recording (proof.mp4)
|
|
8081
|
+
// or just a recording:true flag; the goal's reviewSummary can also name one.
|
|
8082
|
+
// Drives the neutral "Verify recording ↗" meta chip + the Proof video player.
|
|
8083
|
+
// "ないものは載せない": almost all real tasks have none, so both stay hidden.
|
|
8084
|
+
recording: Boolean(pt?.proof?.mp4 || pt?.proof?.recording || g?.reviewSummary?.recording),
|
|
8085
|
+
recordingUrl: pt?.proof?.mp4 ? withKey(`/proof/${pt.id}/${pt.proof.mp4}`) : (typeof g?.reviewSummary?.recording === 'string' ? g.reviewSummary.recording : null),
|
|
8086
|
+
// Local URL (spec §5): a running localhost the reviewer can open ("Local localhost:4809 ↗").
|
|
8087
|
+
// Falls back to the existing "Local run ↗" (runlocal) button when absent.
|
|
8088
|
+
localUrl: g?.reviewSummary?.localUrl ?? null,
|
|
8089
|
+
// Client-side diff fallback: the git-backed /diff endpoint is null for seeded/
|
|
8090
|
+
// offline goals, so "What changed" never renders. A unified-diff string stored on
|
|
8091
|
+
// the goal (or any of its tasks) lets saGhDiffHtml render locally with no server.
|
|
8092
|
+
diffStr: g?.diff ?? (ts.filter((t) => r.goalIds.includes(t.goalId)).map((t) => t.diff).find(Boolean)) ?? null,
|
|
7934
8093
|
// Token-frugality is the product's whole pitch — surface it here too, not
|
|
7935
8094
|
// just in the per-goal review modal. buildReviewSummary computes the same
|
|
7936
8095
|
// estimate; reuse its logic directly off the goal's runUsage/runTokens.
|
|
7937
|
-
tokenOpt:
|
|
8096
|
+
tokenOpt: (typeof g?.reviewSummary?.token === 'number')
|
|
8097
|
+
? { percent: g.reviewSummary.token, basis: 'cache', techniques: ['prompt caching', '/compact'] }
|
|
8098
|
+
: estimateTokenOptimizationLocal({
|
|
7938
8099
|
usage: g?.runUsage, weightedTokens: g?.runTokens, cacheReadWeight: g?.runTokenWeight,
|
|
7939
8100
|
sessionMode: g?.executionPlan?.sessionMode,
|
|
7940
8101
|
usedHandoff: g?.executionPlan?.sessionMode === 'cold-handoff' || Boolean(g?.contextHandoff),
|
|
@@ -7947,32 +8108,9 @@ function saRows(){
|
|
|
7947
8108
|
};
|
|
7948
8109
|
});
|
|
7949
8110
|
}
|
|
7950
|
-
//
|
|
7951
|
-
//
|
|
7952
|
-
//
|
|
7953
|
-
// actually triggers (docs/design/HANDOFF-review-card-evidence-first-2026-07-18.md
|
|
7954
|
-
// §5). Shown only for tasks with a real automated check to plausibly re-run
|
|
7955
|
-
// (it.testResult.ran), and tapping it is honest about not being wired yet rather
|
|
7956
|
-
// than pretending to execute anything — never a fake "running…" state.
|
|
7957
|
-
function saMetaRowHtml(it, i){
|
|
7958
|
-
const canRunLocal = Boolean(it.testResult?.ran);
|
|
7959
|
-
if (!it.pr && !it.tokenOpt && !canRunLocal) {
|
|
7960
|
-
return '<div class="lg-meta"><span class="none">No PR link / token-savings data on this task.</span></div>';
|
|
7961
|
-
}
|
|
7962
|
-
const chips = [];
|
|
7963
|
-
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>`);
|
|
7964
|
-
if (it.tokenOpt) {
|
|
7965
|
-
const pct = Number(it.tokenOpt.percent);
|
|
7966
|
-
const label = pct > 0 ? `saved ${pct}% tokens` : 'token usage';
|
|
7967
|
-
chips.push(`<button class="tokchip" type="button" data-saact="tok:${i}">${esc(label)} <i class="tokchevron">›</i></button>`);
|
|
7968
|
-
}
|
|
7969
|
-
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>`);
|
|
7970
|
-
const tokCmp = it.tokenOpt?.basis === 'baseline' ? 'vs your recent average' : 'from prompt caching';
|
|
7971
|
-
const tokTech = (it.tokenOpt?.techniques ?? []).slice(0, 4).join(' / ');
|
|
7972
|
-
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>` : '';
|
|
7973
|
-
const runNote = canRunLocal ? `<div class="tokdetail" id="runlocal${i}"><span class="v">No local run command is wired for this task yet.</span></div>` : '';
|
|
7974
|
-
return `<div class="lg-meta">${chips.join('')}</div>${tokDetail}${runNote}`;
|
|
7975
|
-
}
|
|
8111
|
+
// (P1, Masa 2026-07-21) saMetaRowHtml() was deleted here — the pre-redesign meta row,
|
|
8112
|
+
// uncalled since the review-card redesign. Its "Run locally" chip only ever opened a
|
|
8113
|
+
// note saying no run command was wired; the card's Open it chip now starts the real one.
|
|
7976
8114
|
// Context: closed by default, one-line verbatim-quote summary, chevron large and at
|
|
7977
8115
|
// the far right (H23 §2). Quotes goal.text — the user's own words — never
|
|
7978
8116
|
// task.detail/title (AI-authored; an earlier round of this design mistakenly quoted
|
|
@@ -7991,37 +8129,89 @@ function saContextHtml(it, gk){
|
|
|
7991
8129
|
const open = SA.detOpen.has(`${gk}:ctx`) ? ' open' : '';
|
|
7992
8130
|
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>`;
|
|
7993
8131
|
}
|
|
7994
|
-
//
|
|
7995
|
-
//
|
|
7996
|
-
//
|
|
7997
|
-
//
|
|
7998
|
-
|
|
7999
|
-
|
|
8000
|
-
|
|
8001
|
-
|
|
8002
|
-
|
|
8003
|
-
|
|
8004
|
-
|
|
8005
|
-
|
|
8006
|
-
|
|
8007
|
-
|
|
8008
|
-
|
|
8009
|
-
|
|
8010
|
-
|
|
8011
|
-
|
|
8012
|
-
|
|
8013
|
-
|
|
8132
|
+
// (P1, Masa 2026-07-21) saEvidenceHtml() was deleted here. It rendered the four
|
|
8133
|
+
// "evidence hero" shapes for the pre-redesign card — including the 'missing' one that
|
|
8134
|
+
// told the reviewer "No capture — this touched the UI and should have one". Nothing has
|
|
8135
|
+
// called it since the review-card redesign, and its premise (we owe you a screenshot)
|
|
8136
|
+
// is exactly what P1 removes. The report the AI wrote, plus Open it, is the evidence.
|
|
8137
|
+
// Unified-diff → GitHub two-gutter model (REVIEW-CARD-SPEC §7 "GitHub diff = Bright"):
|
|
8138
|
+
// walk the @@ hunk headers to recover the old/new line numbers, emit one row per line
|
|
8139
|
+
// {o:oldNo|null, n:newNo|null, t:'add'|'del'|'ctx', c:text}. Word-level highlight is
|
|
8140
|
+
// NOT fabricated from a raw diff (the product never invents evidence) — the Bright
|
|
8141
|
+
// line+gutter tint carries the change and .gh-hi simply stays absent. Pure, so it is
|
|
8142
|
+
// extracted and unit-tested in engine/test/review-card-diff.test.mjs.
|
|
8143
|
+
function saUnifiedToGh(diff, max){
|
|
8144
|
+
const cap = max || 80; const out = []; let o = 0, n = 0;
|
|
8145
|
+
for (const raw of String(diff || '').split('\n')) {
|
|
8146
|
+
if (raw === '') continue; // split artifact / blank input — a real blank ctx line is a single space
|
|
8147
|
+
if (/^(diff --git|index |--- |\+\+\+ |new file|deleted file|old mode|new mode|similarity|rename |Binary )/.test(raw)) continue;
|
|
8148
|
+
const m = raw.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
8149
|
+
if (m) { o = +m[1]; n = +m[2]; continue; }
|
|
8150
|
+
if (raw.startsWith('\\')) continue; // ""
|
|
8151
|
+
if (raw.startsWith('+')) out.push({ o: null, n: n++, t: 'add', c: raw.slice(1) });
|
|
8152
|
+
else if (raw.startsWith('-')) out.push({ o: o++, n: null, t: 'del', c: raw.slice(1) });
|
|
8153
|
+
else out.push({ o: o++, n: n++, t: 'ctx', c: raw.replace(/^ /, '') });
|
|
8154
|
+
if (out.length >= cap) break;
|
|
8014
8155
|
}
|
|
8015
|
-
|
|
8016
|
-
|
|
8017
|
-
|
|
8018
|
-
|
|
8019
|
-
|
|
8020
|
-
|
|
8021
|
-
|
|
8156
|
+
return out;
|
|
8157
|
+
}
|
|
8158
|
+
function saGhDiffHtml(diff){
|
|
8159
|
+
const rows = saUnifiedToGh(diff);
|
|
8160
|
+
if (!rows.length) return '';
|
|
8161
|
+
return saGhDiffRowsHtml(rows);
|
|
8162
|
+
}
|
|
8163
|
+
// One Activity transcript row (spec §3): You / tool (mono, bold name) / res (green) /
|
|
8164
|
+
// Claude. Mirrors the flagship artifact's tx mapping so the pulldown pixel-matches.
|
|
8165
|
+
function saTxRowHtml(e){
|
|
8166
|
+
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>`;
|
|
8167
|
+
if (e[0] === 'res') return `<div class="txr res"><span class="w"></span><span class="m">${esc(String(e[1]).replace(/^✗ /, '').replace(/^✓ /, ''))}</span></div>`;
|
|
8168
|
+
if (e[0] === 'user') return `<div class="txr you"><span class="w">You</span><span class="m">${esc(e[1])}</span></div>`;
|
|
8169
|
+
return `<div class="txr cl"><span class="w">Claude</span><span class="m">${esc(e[1])}</span></div>`;
|
|
8170
|
+
}
|
|
8171
|
+
// Structured GitHub diff (REVIEW-CARD-SPEC §7): renders {o,n,t,c,h} rows with a
|
|
8172
|
+
// word-level highlight (h) and, for a huge change, a "…+N 行 · GitHub で開く ↗"
|
|
8173
|
+
// footer (only linking to GitHub when a PR exists). Mirrors the flagship artifact's
|
|
8174
|
+
// ghDiffHtml so the app pixel-matches. `more` = extra unshown line count; `pr` = url.
|
|
8175
|
+
function saGhDiffRowsHtml(rows, more, pr){
|
|
8176
|
+
if (!rows || !rows.length) return '';
|
|
8177
|
+
const body = '<div class="rdiff2">' + rows.map((l) => {
|
|
8178
|
+
const cls = l.t === 'add' ? 'gh-add' : l.t === 'del' ? 'gh-del' : 'gh-ctx';
|
|
8179
|
+
const mk = l.t === 'add' ? '+' : l.t === 'del' ? '−' : '';
|
|
8180
|
+
let code = esc(l.c);
|
|
8181
|
+
if (l.h) { const h = esc(l.h); code = code.replace(h, `<span class="gh-hi">${h}</span>`); }
|
|
8182
|
+
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>`;
|
|
8183
|
+
}).join('') + '</div>';
|
|
8184
|
+
const foot = more ? `<div class="ghfoot"><span>…+${more} 行</span>${pr ? ` · <a class="ghopen" href="${esc(pr)}" target="_blank" rel="noopener">GitHub で開く ↗</a>` : ''}</div>` : '';
|
|
8185
|
+
return body + foot;
|
|
8186
|
+
}
|
|
8187
|
+
// Screen-recording player for Proof (REVIEW-CARD-SPEC §6). A real file (recordingUrl)
|
|
8188
|
+
// renders a native <video controls>; otherwise a self-contained SVG player mockup
|
|
8189
|
+
// (faint app frame · centred play · scrub bar) matching the artifact's videoFig, with
|
|
8190
|
+
// a "▸ Watch recording · 0:12" caption. Purely presentational — no fabricated evidence
|
|
8191
|
+
// (only rendered when a recording actually exists on the task/goal).
|
|
8192
|
+
function saVideoFigHtml(url){
|
|
8193
|
+
if (url) return `<figure class="rvid"><video class="proofvid" src="${esc(url)}" controls preload="metadata"></video><figcaption>▸ Watch recording</figcaption></figure>`;
|
|
8194
|
+
const P = 'var(--panel)', H = 'var(--hair)';
|
|
8195
|
+
const svg = `<svg viewBox="0 0 560 315" preserveAspectRatio="xMidYMid meet"><rect width="560" height="315" fill="${P}"/>`
|
|
8196
|
+
+ `<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}"/>`
|
|
8197
|
+
+ `<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"/>`
|
|
8198
|
+
+ `<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>`;
|
|
8199
|
+
return `<figure class="rvid">${svg}<figcaption>▸ Watch recording · 0:12</figcaption></figure>`;
|
|
8200
|
+
}
|
|
8201
|
+
// Workflow (REVIEW-CARD-SPEC §8): vertical ✓ / → / □ built ONLY from real signals on
|
|
8202
|
+
// this goal — each ✓ reflects something that actually happened (implemented, tested,
|
|
8203
|
+
// proof captured); the card's own "In review" is the → here; merge/deploy are ahead.
|
|
8204
|
+
// No fabricated pipeline: an untested goal shows □ Tested, not a green ✓.
|
|
8205
|
+
function saWorkflowHtml(it){
|
|
8206
|
+
const ran = Boolean(it.testResult?.ran);
|
|
8207
|
+
// P1 (Masa 2026-07-21): "Proof captured □" is gone. We no longer go take photos of
|
|
8208
|
+
// the work, so an empty box there was scoring the goal against a step that isn't
|
|
8209
|
+
// part of the process any more — a permanent unchecked box nobody could ever check.
|
|
8210
|
+
const steps = [['Implemented', 'ok'], ['Tested', ran ? 'ok' : 'todo'], ['In review', 'now'], ['Merge', 'todo'], ['Deploy', 'todo']];
|
|
8211
|
+
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>';
|
|
8022
8212
|
}
|
|
8023
8213
|
function saItemHtml(it, i){
|
|
8024
|
-
const done = { 1: ['ok', '✓', 'Approved'], 2: ['no', '↩', '
|
|
8214
|
+
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];
|
|
8025
8215
|
if (done) {
|
|
8026
8216
|
// Revert deliberately has NO undo: the PR is closed and its branch deleted —
|
|
8027
8217
|
// an "UNDO" here would be a lie (HANDOFF-v45 §5.3 honesty over symmetry).
|
|
@@ -8029,99 +8219,238 @@ function saItemHtml(it, i){
|
|
|
8029
8219
|
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>`;
|
|
8030
8220
|
}
|
|
8031
8221
|
const d = SA.diffs[it.goalId];
|
|
8032
|
-
// Honest evidence chips (mirrors the vchip tested/proof/needs-you logic in
|
|
8033
|
-
// renderReviewChecklist ~L3742-3752): absence of a signal is shown as a muted
|
|
8034
|
-
// chip, never silently dropped — an untested/proofless goal must never LOOK verified.
|
|
8035
|
-
// H23: facts stop looking like buttons — glyph carries the state colour, text
|
|
8036
|
-
// stays plain ink, no border/pill (border is reserved for the PR link).
|
|
8037
8222
|
const gtr = it.testResult;
|
|
8038
|
-
const testsChip = gtr?.ran
|
|
8039
|
-
? (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>`)
|
|
8040
|
-
: '<span class="vf none">— not tested</span>';
|
|
8041
8223
|
const hasProofImg = it.proofImg && it.proofTaskId != null;
|
|
8042
|
-
//
|
|
8043
|
-
//
|
|
8044
|
-
|
|
8045
|
-
//
|
|
8046
|
-
//
|
|
8047
|
-
//
|
|
8048
|
-
//
|
|
8049
|
-
//
|
|
8050
|
-
|
|
8051
|
-
const boxedImg = SA.diffBoxHtml[it.goalId];
|
|
8052
|
-
const img = hasProofImg
|
|
8053
|
-
? (typeof boxedImg === 'string' ? boxedImg : beforeAfterMediaHtml(it.proofTaskId, { png: it.proofImg, beforePng: it.proofBefore }, 'proof'))
|
|
8054
|
-
: '';
|
|
8055
|
-
// Changed-file paths feed both the evidence-kind heuristic and the docs-kind diff —
|
|
8056
|
-
// same source the old "Changed files & diff" fold used (d from the diff endpoint,
|
|
8057
|
-
// falling back to task.changedFiles when the server has no diff for this goal).
|
|
8224
|
+
// Proof image (P1): the single capture from a check the AI itself promised and ran.
|
|
8225
|
+
// No pair, no red box — those compared against a "before" shot we used to stage.
|
|
8226
|
+
const img = hasProofImg ? proofMediaHtml(it.proofTaskId, { png: it.proofImg }, esc(it.title ?? '')) : '';
|
|
8227
|
+
// Changed-file paths: d from the diff endpoint ({add,del,files:[{path,add,del}],diff}),
|
|
8228
|
+
// falling back to task.changedFiles when the server has no diff for this goal.
|
|
8229
|
+
// A usable git diff has actual files; the endpoint returns an empty {add:0,del:0,
|
|
8230
|
+
// files:[],diff:''} object for goals with no committed range (seeded/uncommitted),
|
|
8231
|
+
// which must NOT shadow the client-side fallback below.
|
|
8232
|
+
const hasGitDiff = Boolean(d && d.diff);
|
|
8058
8233
|
let filePaths = [], nf = 0;
|
|
8059
|
-
if (d) { filePaths = d.files.map((f) => f.path); nf = filePaths.length; }
|
|
8060
|
-
else
|
|
8234
|
+
if (d && d.files.length) { filePaths = d.files.map((f) => f.path); nf = filePaths.length; }
|
|
8235
|
+
else {
|
|
8236
|
+
// no usable git diff: fall back to the tasks' own changedFiles so Files / What
|
|
8237
|
+
// changed still populate for seeded/offline goals.
|
|
8061
8238
|
filePaths = [...new Set((state.tasks || []).filter((t) => it.ids.includes(t.goalId)).flatMap((t) => t.changedFiles ?? []))];
|
|
8062
8239
|
nf = filePaths.length;
|
|
8063
8240
|
}
|
|
8241
|
+
// Client-side unified diff (spec §4 fallback): render "What changed" locally when the
|
|
8242
|
+
// server has no usable git diff. Line totals feed the Files chip's +add / −del.
|
|
8243
|
+
const clientDiff = (!hasGitDiff && it.diffStr) ? it.diffStr : null;
|
|
8244
|
+
let cAdd = 0, cDel = 0;
|
|
8245
|
+
if (clientDiff) for (const l of saUnifiedToGh(clientDiff, 2000)) { if (l.t === 'add') cAdd++; else if (l.t === 'del') cDel++; }
|
|
8246
|
+
// Recorded display fields (spec §11: card shape follows the task). A goal's reviewSummary
|
|
8247
|
+
// may carry an explicit file list; prefer it (real git-backed goals keep the computed
|
|
8248
|
+
// filePaths above). Split the "path +N −M" string down to the bare path for kind/log.
|
|
8249
|
+
if (it.metaFiles) { filePaths = it.metaFiles.map((f) => String(f).split(/\s{2,}/)[0]); nf = it.metaFiles.length; }
|
|
8064
8250
|
const kind = saEvidenceKind(filePaths, hasProofImg, gtr);
|
|
8065
|
-
const factRow = [
|
|
8066
|
-
testsChip,
|
|
8067
|
-
it.note ? `<span class="vf ok"><i class="g">✓</i>${esc(it.note)}</span>` : '',
|
|
8068
|
-
`<span class="fls">${nf} file${nf === 1 ? '' : 's'}</span>`,
|
|
8069
|
-
hasProofImg ? '' : '<span class="vf none">— no proof</span>',
|
|
8070
|
-
].filter(Boolean).join('');
|
|
8071
8251
|
const gk = it.goalId;
|
|
8072
|
-
|
|
8073
|
-
//
|
|
8074
|
-
//
|
|
8075
|
-
const
|
|
8076
|
-
|
|
8077
|
-
|
|
8078
|
-
|
|
8079
|
-
|
|
8080
|
-
|
|
8081
|
-
|
|
8082
|
-
|
|
8252
|
+
const rs = it.goal?.reviewSummary || {};
|
|
8253
|
+
// Conversational task (no code change): drops Proof + What changed, shows an Answer
|
|
8254
|
+
// instead (spec §11); its Activity IS the conversation.
|
|
8255
|
+
const isConv = Boolean(it.answer);
|
|
8256
|
+
// ── meta (spec §5) — fully data-driven / hide-empty: each chip renders only when its
|
|
8257
|
+
// data exists (no PR → no PR chip · no tests → no Tests · no file totals → no Files ·
|
|
8258
|
+
// no recording → no Verify). PR = bold green, no underline (a real <a href>). ──
|
|
8259
|
+
const arw = '<span class="ar">↗</span>';
|
|
8260
|
+
const prNum = it.pr ? (it.pr.match(/pull\/(\d+)/) ?? [, ''])[1] : '';
|
|
8261
|
+
const tokPct = it.tokenOpt ? Number(it.tokenOpt.percent) : 0;
|
|
8262
|
+
const testsLabel = gtr?.ran ? (gtr.label || (gtr.ok ? `${gtr.passed} passed` : `${gtr.passed}/${gtr.passed + (gtr.failed || 0)} passed`)) : '';
|
|
8263
|
+
// Files totals: recorded strings (metaAdd/metaDel, already signed) win, else git/client.
|
|
8264
|
+
const fAdd = it.metaAdd ?? (hasGitDiff ? `+${d.add}` : clientDiff ? `+${cAdd}` : null);
|
|
8265
|
+
const fDel = it.metaDel ?? (hasGitDiff ? `−${d.del}` : clientDiff ? `−${cDel}` : null);
|
|
8266
|
+
const chips = [];
|
|
8267
|
+
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>`);
|
|
8268
|
+
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>`);
|
|
8269
|
+
if (testsLabel) chips.push(`<span class="mx"><span class="mk">Tests</span> <b class="ok">${esc(testsLabel)}</b></span>`);
|
|
8270
|
+
// Files: hide-empty — only with changed files AND totals (a bare-count chip reads odd).
|
|
8271
|
+
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>`);
|
|
8272
|
+
// Local: an open localhost URL (neutral, not green). A bare host:port isn't navigable in
|
|
8273
|
+
// the demo, so it renders as a non-navigating chip; a real URL opens in a new tab.
|
|
8274
|
+
if (it.localUrl) { const lu = String(it.localUrl); const isUrl = /^https?:/.test(lu);
|
|
8275
|
+
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>`); }
|
|
8276
|
+
// (Open it is NOT a meta chip — Masa 2026-07-22. It was saying the same thing as the
|
|
8277
|
+
// Open it section below, in the PR chip's vocabulary, so the card spoke two languages
|
|
8278
|
+
// about one thing. It lives in the body now, in the same typography as every other
|
|
8279
|
+
// section.)
|
|
8280
|
+
// Verify = a screen recording of the check (spec §5): NEUTRAL. Opens the file when there
|
|
8281
|
+
// is a real URL, otherwise scrolls to the in-card player.
|
|
8282
|
+
if (it.recording) chips.push(it.recordingUrl
|
|
8283
|
+
? `<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>`
|
|
8284
|
+
: `<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>`);
|
|
8285
|
+
const tokCmp = it.tokenOpt?.basis === 'baseline' ? 'vs your recent average' : 'from prompt caching';
|
|
8286
|
+
const tokTech = (it.tokenOpt?.techniques ?? []).slice(0, 4).join(' / ');
|
|
8287
|
+
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>` : '';
|
|
8288
|
+
// §1.5-3: with Open it moved into the body, a card with no PR / tokens / tests has no
|
|
8289
|
+
// chips at all — render no meta row rather than an empty one holding open a gap.
|
|
8290
|
+
const meta = chips.length ? `<div class="rmeta">${chips.join('')}</div>${metaDetail}` : '';
|
|
8291
|
+
// ── Request (spec §4): a PLAIN section — the user's own short words (goal.text). Never
|
|
8292
|
+
// an accordion, never the implementation spec (that lives in Activity below). ──
|
|
8293
|
+
const reqSec = it.goalText
|
|
8294
|
+
? `<section class="rsec rreq"><div class="rsec-h">Request</div><p class="rbody rask">${esc(it.goalText)}</p></section>`
|
|
8295
|
+
: '';
|
|
8296
|
+
// ── Open it (Masa 2026-07-22): a plain section in the card's own typography — the same
|
|
8297
|
+
// heading + body shape as Request and Result, not a chip in another vocabulary. The
|
|
8298
|
+
// address the worker declared IS the control (press it → the process starts on demand →
|
|
8299
|
+
// it opens); its own note sits under it, and that note is where the status and any
|
|
8300
|
+
// failure reason land after the press. Nothing declared → no section (§1.5-3). ──
|
|
8301
|
+
const openBody = it.run
|
|
8302
|
+
? `<div class="ropen" id="runlocal${i}"><button type="button" class="olink" data-saact="preview:${i}" id="prevlink${i}">${esc(it.run.url || 'Start it')}<span class="ar">↗</span></button><p class="rbody v">${esc(it.run.note || it.run.cmd || 'Starts when you press it.')}</p></div>`
|
|
8303
|
+
: '';
|
|
8304
|
+
// ── Also carries (Masa 2026-07-21): goals folded onto this review are logged HERE, inside
|
|
8305
|
+
// the review's own content — not as loose "→ #12 にまとめました" rows in the Review column.
|
|
8306
|
+
// Chrome label stays English (§377-7); the request titles keep whatever language they were
|
|
8307
|
+
// written in. Only rendered when something was actually folded in (§1.5-3 no empty box). ──
|
|
8308
|
+
const carriesBody = it.folded?.length
|
|
8309
|
+
? `<ul class="rcheck">${it.folded.map((f) => `<li>#${esc(String(f.id))} ${esc(f.title)}</li>`).join('')}</ul>`
|
|
8310
|
+
: '';
|
|
8311
|
+
// ── What to check (spec §2.5): a bulleted list when several, a line when one. ──
|
|
8312
|
+
// Only an explicit review-summary check renders here — never the goal text echoed back
|
|
8313
|
+
// (it.check falls back to goal.text via checkLine, which would fabricate a "What to
|
|
8314
|
+
// check" for a conversational task that has none). §0.5 no placeholder.
|
|
8315
|
+
// The working AI's own sentence wins (P1, Masa 2026-07-22): it declares what to look
|
|
8316
|
+
// at in the same file it declares how to run the thing. reviewSummary.check below is
|
|
8317
|
+
// the intake AI's invention and only fills in for goals that predate the declaration.
|
|
8318
|
+
const checkArr = it.run?.check ? [it.run.check]
|
|
8319
|
+
: Array.isArray(rs.checkList) ? rs.checkList.filter(Boolean)
|
|
8320
|
+
: Array.isArray(rs.check) ? rs.check.filter(Boolean)
|
|
8321
|
+
: (typeof rs.check === 'string' && rs.check.trim() ? [rs.check.trim()] : []);
|
|
8322
|
+
const checkBody = checkArr.length > 1
|
|
8323
|
+
? `<ul class="rcheck">${checkArr.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>`
|
|
8324
|
+
: (checkArr.length ? `<p class="rbody rcheck1">${esc(checkArr[0])}</p>` : '');
|
|
8325
|
+
// ── Steps (P0b, Masa 2026-07-21): the AI's own TodoWrite breakdown — distinct from
|
|
8326
|
+
// "What to check" (the human's pass condition) and Activity (the raw tool-call log).
|
|
8327
|
+
// Reuses the same .todos/.todo vocabulary as the chat feed / board card so the ✓/▸/○
|
|
8328
|
+
// language is consistent everywhere it appears. Empty → no section (§1.5-3).
|
|
8329
|
+
const stepsBody = it.todosFinal?.length ? `<div class="todos">${todoRowsHtml(it.todosFinal)}</div>` : '';
|
|
8330
|
+
// ── Result (P1, Masa 2026-07-21): the AI's own report, rendered as written and read
|
|
8331
|
+
// whole (no 160-char squeeze, no summariser of ours in the way). This is the one
|
|
8332
|
+
// section that carries every kind of ask — a研究 report reads here as prose, a UI
|
|
8333
|
+
// change explains itself here, and an image the AI pasted shows up here as an image.
|
|
8334
|
+
// We interpret nothing; mdLite only turns Markdown into the shapes it already means. ──
|
|
8335
|
+
const reportBody = it.report ? `<div class="rreport">${mdLite(it.report, { images: false })}</div>` : '';
|
|
8336
|
+
// ── Proof (spec §6/§11): before/after (identical size) when a real image exists; a
|
|
8337
|
+
// run-log for logic-only tests; dropped entirely for a conversational task. ──
|
|
8338
|
+
let proofInner = '';
|
|
8339
|
+
if (!isConv) {
|
|
8340
|
+
if (hasProofImg) proofInner = `<div class="rproof">${img}</div>`;
|
|
8341
|
+
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>`;
|
|
8342
|
+
if (it.recording) proofInner += saVideoFigHtml(it.recordingUrl);
|
|
8343
|
+
}
|
|
8344
|
+
// ── What changed (spec §4/§7): description + files summary (always) + the diff, which
|
|
8345
|
+
// peeks then "…+N 行 · GitHub で開く ↗" for a huge change (no GitHub link when there's no
|
|
8346
|
+
// PR). Structured recorded rows (ghRows) win over a git/client unified diff. Dropped
|
|
8347
|
+
// entirely for a conversational task. ──
|
|
8348
|
+
// No prose here (P1, Masa 2026-07-22): reviewSummary.changed was the intake AI writing
|
|
8349
|
+
// its own summary of the change, and on a real goal it came out as the request echoed
|
|
8350
|
+
// back. What changed is now only what actually changed — files and the diff.
|
|
8351
|
+
const changedDescHtml = '';
|
|
8352
|
+
const filesListHtml = it.metaFiles
|
|
8353
|
+
? `<div class="rfiles">${it.metaFiles.map((f) => esc(f)).join('<br>')}</div>`
|
|
8354
|
+
: (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>`
|
|
8355
|
+
: (clientDiff && filePaths.length) ? `<div class="rfiles">${filePaths.map((p) => esc(p)).join('<br>')}</div>` : '';
|
|
8356
|
+
const diffHtml = it.ghRows
|
|
8357
|
+
? saGhDiffRowsHtml(it.ghRows, it.diffTotal || 0, it.pr || '')
|
|
8358
|
+
: hasGitDiff ? saGhDiffHtml(d.diff) : clientDiff ? saGhDiffHtml(clientDiff) : '';
|
|
8359
|
+
const changedBody = isConv ? '' : (changedDescHtml + filesListHtml + diffHtml);
|
|
8360
|
+
// ── sensitive-area note (real: classifyChangeRisk forced the pause) ──
|
|
8083
8361
|
const risk = it.risk;
|
|
8084
8362
|
const riskHtml = risk?.shouldPause
|
|
8085
8363
|
? `<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>`
|
|
8086
8364
|
: '';
|
|
8087
|
-
|
|
8088
|
-
|
|
8089
|
-
|
|
8090
|
-
|
|
8091
|
-
|
|
8365
|
+
// ── Activity (spec §3): ONE Linear-docs pulldown. The "Activity" heading is plain; the
|
|
8366
|
+
// chevron sits on the FIRST line (the opening "You …" row); tapping it opens the rest
|
|
8367
|
+
// below, offset-indented. The review Discussion folds in at the bottom of the body. The
|
|
8368
|
+
// transcript is the recorded build (activity); real goals fall back to "what we did".
|
|
8369
|
+
// Persisted open-state via data-detkey so SSE re-renders don't collapse it. ──
|
|
8370
|
+
// The "what we did" fallback is the same report as Result above, cut to 160 chars —
|
|
8371
|
+
// showing both makes the card repeat itself in a worse form (P1). Only fall back when
|
|
8372
|
+
// there is no Result to read.
|
|
8373
|
+
const txArr = (it.activity && it.activity.length)
|
|
8374
|
+
? it.activity.map((e) => saTxRowHtml(e))
|
|
8375
|
+
: ((!reportBody && it.did?.length) ? it.did.map((s) => saTxRowHtml(['claude', s])) : []);
|
|
8376
|
+
const discArr = it.discussion || it.thread || null;
|
|
8377
|
+
const threadHtml = (discArr && discArr.length)
|
|
8378
|
+
? 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('')
|
|
8379
|
+
: '';
|
|
8380
|
+
const actOpen = SA.detOpen.has(`${gk}:act`) ? ' open' : '';
|
|
8381
|
+
const txFirst = txArr[0] || '<div class="txr"><span class="w"></span><span class="m">Activity</span></div>';
|
|
8382
|
+
const txRest = txArr.slice(1).join('');
|
|
8383
|
+
const discInner = threadHtml ? `<div class="rsub">Discussion</div><div class="rthread">${threadHtml}</div>` : '';
|
|
8384
|
+
const activitySec = (txArr.length || threadHtml)
|
|
8385
|
+
? `<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>`
|
|
8386
|
+
: '';
|
|
8387
|
+
const S = (label, body) => body ? `<section class="rsec"><div class="rsec-h">${label}</div>${body}</section>` : '';
|
|
8388
|
+
return `<div class="sa-item lg redesign${it.goalId === SA.cur ? ' cur' : ''}" data-gid="${it.goalId}">
|
|
8389
|
+
<div class="rhead"><span class="rtag">${it.no != null ? `#${it.no}` : ''}</span><span class="rst"><span class="rdot"></span>In review</span></div>
|
|
8390
|
+
<h2 class="rtitle">${esc(it.title)}</h2>
|
|
8391
|
+
${meta}
|
|
8392
|
+
${reqSec}
|
|
8393
|
+
${S('Open it', openBody)}
|
|
8394
|
+
${S('Also carries', carriesBody)}
|
|
8395
|
+
${S('What to check', checkBody)}
|
|
8396
|
+
${S('Result', reportBody)}
|
|
8397
|
+
${S('Steps', stepsBody)}
|
|
8398
|
+
${S('Answer', it.answer ? `<p class="rbody">${esc(it.answer)}</p>` : '')}
|
|
8399
|
+
${S('Proof', proofInner)}
|
|
8400
|
+
${S('What changed', changedBody)}
|
|
8401
|
+
${S('Not done', it.risk?.summary ? `<p class="rbody rrisk">${esc(it.risk.summary)}</p>` : '')}
|
|
8092
8402
|
${riskHtml}
|
|
8093
|
-
|
|
8403
|
+
${activitySec}
|
|
8404
|
+
${S('Workflow', saWorkflowHtml(it))}
|
|
8405
|
+
<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>
|
|
8094
8406
|
</div>`;
|
|
8095
8407
|
}
|
|
8408
|
+
// Scroll the See-all body so a card's top edge (.rhead) sits at the top of the visible
|
|
8409
|
+
// scroll area — the reviewer always starts reading a card from its header, never partway
|
|
8410
|
+
// down (Masa 2026-07-20). Uses a rect delta so it works regardless of offsetParent.
|
|
8411
|
+
function saScrollCardToTop(el){
|
|
8412
|
+
const body = document.querySelector('#seeall .sa-body');
|
|
8413
|
+
if (!body || !el) return;
|
|
8414
|
+
body.scrollTop += el.getBoundingClientRect().top - body.getBoundingClientRect().top;
|
|
8415
|
+
}
|
|
8096
8416
|
function saRender(){
|
|
8097
8417
|
const S = $('seeall'); if (!S) return;
|
|
8098
8418
|
const pending = SA.items.filter((x) => x.st === 0);
|
|
8099
8419
|
const green = pending.filter((x) => !x.testResult || x.testResult.ok !== false).length;
|
|
8100
|
-
const
|
|
8101
|
-
const c = `${pending.length} ready · ${green === pending.length ? 'all green' : `${pending.length - green} failing`} · ${proofN} with proof`;
|
|
8420
|
+
const c = `${pending.length} ready · ${green === pending.length ? 'all green' : `${pending.length - green} failing`}`;
|
|
8102
8421
|
const curIdx = pending.findIndex((x) => x.goalId === SA.cur);
|
|
8103
8422
|
const posLabel = pending.length && curIdx >= 0 ? `${curIdx + 1} / ${pending.length}` : '';
|
|
8104
|
-
S.innerHTML = `<div class="sa-panel"><div class="sa-hd"><span class="t">Review</span><span class="c">${c}</span><span class="sa-count">${posLabel}</span><span class="keys">↑↓ MOVE · A APPROVE · D
|
|
8423
|
+
S.innerHTML = `<div class="sa-panel"><div class="sa-hd"><span class="t">Review</span><span class="c">${c}</span><span class="sa-count">${posLabel}</span><span class="keys">↑↓ MOVE · A APPROVE · D DISMISS · ESC CLOSE</span><button class="sa-x" data-saact="close:0">✕</button></div>
|
|
8105
8424
|
<div class="sa-body">${SA.items.length ? SA.items.map((it, i) => saItemHtml(it, i)).join('') : '<div class="sa-empty">Nothing waiting for review.</div>'}</div></div>`;
|
|
8106
8425
|
// Native <details> would collapse on every rebuild (SSE tick, approve, etc.) —
|
|
8107
8426
|
// track what's open per goal+section in SA.detOpen (persists across re-renders)
|
|
8108
8427
|
// and wire each one's toggle to keep that set current.
|
|
8109
|
-
for (const det of S.querySelectorAll('
|
|
8428
|
+
for (const det of S.querySelectorAll('[data-detkey]')) {
|
|
8110
8429
|
det.addEventListener('toggle', () => {
|
|
8111
8430
|
const k = det.dataset.detkey;
|
|
8112
8431
|
if (det.open) SA.detOpen.add(k); else SA.detOpen.delete(k);
|
|
8113
8432
|
});
|
|
8114
8433
|
}
|
|
8115
|
-
//
|
|
8116
|
-
//
|
|
8117
|
-
//
|
|
8118
|
-
//
|
|
8119
|
-
|
|
8434
|
+
// "ないものは載せない" (Masa): a Proof <img> whose file is missing/broken must never
|
|
8435
|
+
// show a broken-image placeholder. proofImg is only a stored filename, so a file that
|
|
8436
|
+
// isn't on disk (or fails to load) can only be caught at load time — drop the whole
|
|
8437
|
+
// Proof section if any of its images fails. Real captures load and stay.
|
|
8438
|
+
for (const im of S.querySelectorAll('.redesign .rproof img')) {
|
|
8439
|
+
const drop = () => { const sec = im.closest('section.rsec'); if (sec) sec.remove(); };
|
|
8440
|
+
if (im.complete && im.naturalWidth === 0) drop();
|
|
8441
|
+
else im.addEventListener('error', drop, { once: true });
|
|
8442
|
+
}
|
|
8443
|
+
// H14: if a single-review focus is pending, bring the card's HEADER (#tag + In review
|
|
8444
|
+
// + title) to the very top of the scroll area — never mid-card (Masa 2026-07-20) — and
|
|
8445
|
+
// flash a blue ring. Re-scroll on EVERY render for the focus window — the post-diff
|
|
8446
|
+
// saSync rebuild resets .sa-body scroll to the top, so a one-shot scroll would land back
|
|
8447
|
+
// there. Self-clears after ~1.4s (outlasts the localhost diff fetch) so live SSE ticks
|
|
8448
|
+
// stop yanking the view.
|
|
8120
8449
|
if (SA.focusGid != null) {
|
|
8121
8450
|
requestAnimationFrame(() => {
|
|
8122
8451
|
const el = S.querySelector(`.sa-item[data-gid="${SA.focusGid}"]`);
|
|
8123
8452
|
if (!el) return;
|
|
8124
|
-
el
|
|
8453
|
+
saScrollCardToTop(el);
|
|
8125
8454
|
el.classList.add('sa-flash');
|
|
8126
8455
|
if (!SA.focusTimer) SA.focusTimer = setTimeout(() => { SA.focusGid = null; SA.focusTimer = null; }, 1400);
|
|
8127
8456
|
});
|
|
@@ -8153,25 +8482,6 @@ async function saFetchDiffs(){
|
|
|
8153
8482
|
}));
|
|
8154
8483
|
if (SA.open) saSync();
|
|
8155
8484
|
}
|
|
8156
|
-
// Diff-box + zoom inset (HANDOFF-review-card-diffbox-2026-07-18): unlike
|
|
8157
|
-
// saFetchDiffs above (whose fetch settling triggers a full saSync()/saRender()), this
|
|
8158
|
-
// patches each card's already-rendered .beforeafter node directly — a full re-render
|
|
8159
|
-
// here would reset scroll, close every open <details>, and drop an in-progress reply
|
|
8160
|
-
// keystroke, all just to draw a box around one screenshot. SA.diffBoxHtml is still
|
|
8161
|
-
// cached per goal, so if a later SSE-driven saSync() does rebuild the card anyway,
|
|
8162
|
-
// saItemHtml() picks the cached result back up with no flicker to the plain pair.
|
|
8163
|
-
async function saEnhanceDiffBoxes(){
|
|
8164
|
-
const targets = SA.items.filter((it) => it.proofImg && it.proofTaskId != null && it.proofBefore && SA.diffBoxHtml[it.goalId] === undefined);
|
|
8165
|
-
await Promise.all(targets.map(async (it) => {
|
|
8166
|
-
let html = null;
|
|
8167
|
-
try { html = await beforeAfterBoxedHtml(it.proofTaskId, { png: it.proofImg, beforePng: it.proofBefore }, esc(it.title ?? '')); }
|
|
8168
|
-
catch { html = null; } // a decode/pixel-diff failure is a fallback, never a crash
|
|
8169
|
-
SA.diffBoxHtml[it.goalId] = html; // cache even on null — never re-attempt an already-answered "no box"
|
|
8170
|
-
if (!html || !SA.open) return;
|
|
8171
|
-
const host = document.querySelector(`.sa-item[data-gid="${it.goalId}"] .beforeafter`);
|
|
8172
|
-
if (host) host.outerHTML = html; // html itself opens with the replacement .beforeafter
|
|
8173
|
-
}));
|
|
8174
|
-
}
|
|
8175
8485
|
function saOpen(focusGoalId){
|
|
8176
8486
|
SA.open = true;
|
|
8177
8487
|
SA.items = saRows().map((r) => ({ ...r, st: 0 }));
|
|
@@ -8192,8 +8502,15 @@ function saOpen(focusGoalId){
|
|
|
8192
8502
|
}
|
|
8193
8503
|
saRender();
|
|
8194
8504
|
$('seeall').classList.add('open');
|
|
8505
|
+
// Land the opening card's header at the very top of the scroll area (Masa 2026-07-20).
|
|
8506
|
+
// With focusGid set, saRender's focus block already does this; without it (a plain
|
|
8507
|
+
// "See all"), resolved one-liners can sit above the first pending card, so scrollTop 0
|
|
8508
|
+
// isn't enough — scroll the current card's header to the top explicitly.
|
|
8509
|
+
if (SA.focusGid == null && SA.cur != null) requestAnimationFrame(() => {
|
|
8510
|
+
const el = $('seeall').querySelector(`.sa-item[data-gid="${SA.cur}"]`);
|
|
8511
|
+
if (el) saScrollCardToTop(el);
|
|
8512
|
+
});
|
|
8195
8513
|
saFetchDiffs();
|
|
8196
|
-
saEnhanceDiffBoxes();
|
|
8197
8514
|
}
|
|
8198
8515
|
function saClose(){ SA.open = false; SA.focusGid = null; if (SA.focusTimer) { clearTimeout(SA.focusTimer); SA.focusTimer = null; } $('seeall').classList.remove('open'); }
|
|
8199
8516
|
// Continuous review (Masa 2026-07-08): move a persistent "current" card with
|
|
@@ -8252,10 +8569,34 @@ $('seeall').addEventListener('click', async (e) => {
|
|
|
8252
8569
|
const it = SA.items[i];
|
|
8253
8570
|
if (k === 'close') return saClose();
|
|
8254
8571
|
if (!it) return;
|
|
8255
|
-
// meta-row
|
|
8256
|
-
// Run-locally to reveal the honest "not wired yet" note — neither performs an action.
|
|
8572
|
+
// meta-row toggle (H23): tap the token chip to reveal the technique breakdown.
|
|
8257
8573
|
if (k === 'tok') { b.classList.toggle('open'); $('seeall').querySelector(`#tokdetail${i}`)?.classList.toggle('open'); return; }
|
|
8258
|
-
|
|
8574
|
+
// Open it (P1, Masa 2026-07-21): start the thing the worker said to run, then open it.
|
|
8575
|
+
// Booting a real process takes seconds, so the chip says "Starting…" while it waits —
|
|
8576
|
+
// and a failure lands in the detail line as a plain reason. It never turns the goal
|
|
8577
|
+
// red: failing to boot a preview says nothing about whether the work itself is good.
|
|
8578
|
+
if (k === 'preview') {
|
|
8579
|
+
if (!it.run?.taskId) return;
|
|
8580
|
+
const sec = $('seeall').querySelector(`#runlocal${i}`);
|
|
8581
|
+
const link = $('seeall').querySelector(`#prevlink${i}`);
|
|
8582
|
+
const v = sec?.querySelector('.v');
|
|
8583
|
+
if (link) link.disabled = true;
|
|
8584
|
+
if (v) v.textContent = 'Starting it…';
|
|
8585
|
+
let r = null, body = {};
|
|
8586
|
+
try { r = await fetch(`/api/tasks/${it.run.taskId}/preview`, { method: 'POST' }); body = await r.json().catch(() => ({})); }
|
|
8587
|
+
catch (e) { body = { error: String(e.message ?? e) }; }
|
|
8588
|
+
if (link) link.disabled = false;
|
|
8589
|
+
const noteText = it.run.note || it.run.cmd || 'Starts when you press it.';
|
|
8590
|
+
// A tab opened: that IS the feedback. Put the worker's own note back rather than
|
|
8591
|
+
// narrating what the reviewer just watched happen.
|
|
8592
|
+
if (r?.ok && body.url) { window.open(body.url, '_blank', 'noopener'); if (v) v.textContent = noteText; return; }
|
|
8593
|
+
// Started, but the worker declared no URL: its own note is where it said to look.
|
|
8594
|
+
if (r?.ok) { if (v) v.textContent = it.run.note || it.run.cmd || 'Started — nothing to open.'; return; }
|
|
8595
|
+
if (v) v.textContent = `Could not start it: ${body.error || `HTTP ${r?.status ?? 'error'}`}`;
|
|
8596
|
+
return;
|
|
8597
|
+
}
|
|
8598
|
+
// Verify chip (no external recording URL) → scroll the in-card video player into view.
|
|
8599
|
+
if (k === 'rec') { $('seeall').querySelector(`.sa-item[data-gid="${it.goalId}"] .rvid`)?.scrollIntoView({ behavior: 'smooth', block: 'center' }); return; }
|
|
8259
8600
|
// every verdict auto-advances the focus to the next pending card (tempo).
|
|
8260
8601
|
if (k === 'app') { const bx = b.getBoundingClientRect(); return saAct(it, 1, 'approve').then((ok) => {
|
|
8261
8602
|
if (!ok) return;
|