@galda/cli 0.10.39 → 0.10.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/index.html +915 -120
- package/engine/lib.mjs +375 -87
- package/engine/server.mjs +374 -101
- package/engine/shot.mjs +16 -1
- package/package.json +1 -1
package/app/index.html
CHANGED
|
@@ -267,6 +267,57 @@
|
|
|
267
267
|
.appmenu{position:absolute;left:0;bottom:calc(100% + 6px);min-width:150px;background:var(--surface);border:1px solid var(--hair2);
|
|
268
268
|
border-radius:10px;padding:4px;display:none;flex-direction:column;gap:1px;box-shadow:0 10px 28px rgba(0,0,0,.5);z-index:50}
|
|
269
269
|
.appmenu.show{display:flex}
|
|
270
|
+
/* Context row — the Claude app's shape: separate chips above the box. The chips are
|
|
271
|
+
the composer's OWN control grammar (.appbtn: 26px tall, r8, 1px --hair, transparent,
|
|
272
|
+
11.5px, --ink2), because one input box must not have two chip languages above and
|
|
273
|
+
below it (§1.5 器を増やさない/§5.5 面の処理はパネル全体に均一). Strength comes from
|
|
274
|
+
the TEXT — only the repo name is 600/--ink — never from a louder container.
|
|
275
|
+
CDO handoff 2026-07-23 §A1. `var(--chipbg)` is gone on purpose: it is declared inside
|
|
276
|
+
.gearpop{}, so out here it was invalid at computed-value time and fell back to
|
|
277
|
+
transparent — measured rgba(0,0,0,0). The chips were never filled to begin with. */
|
|
278
|
+
.ctxbar{display:flex;align-items:center;gap:6px;padding:0 2px 7px;background:none;border:0}
|
|
279
|
+
.ctxchip{display:inline-flex;align-items:center;height:26px;padding:0;gap:0;border-radius:8px;
|
|
280
|
+
border:1px solid var(--hair);background:transparent;position:relative;min-width:0}
|
|
281
|
+
.ctxsel{position:relative}
|
|
282
|
+
.ctxseg{display:inline-flex;align-items:center;gap:6px;height:24px;padding:0 8px;border-radius:7px;
|
|
283
|
+
color:var(--ink2);font-size:11.5px;cursor:pointer;transition:color .12s;white-space:nowrap;
|
|
284
|
+
min-width:0;border:0;background:transparent}
|
|
285
|
+
.ctxseg:hover{color:var(--ink)} /* glyph colour only — no surface (§5.5) */
|
|
286
|
+
.ctxseg svg{width:13px;height:13px;flex:0 0 13px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
|
|
287
|
+
.ctxseg b{font-weight:600;font-size:11.5px;color:var(--ink);overflow:hidden;text-overflow:ellipsis;max-width:300px}
|
|
288
|
+
.ctxdiv{width:1px;align-self:stretch;margin:5px 0;background:var(--hair);flex:0 0 1px}
|
|
289
|
+
/* worktree — Masa 2026-07-23: 「claude code は ✓ が外れるだけなのでそれでいきたい。機能も
|
|
290
|
+
claude code と同じにしたい」。OFF just loses its tick and sinks; no amber, no note, no
|
|
291
|
+
dialog. ON is --ink, never --green: in this product green means "it passed" (PR green,
|
|
292
|
+
tests green, Token green), and a settings checkbox borrowing it makes that green cheap.
|
|
293
|
+
The tick is knocked out in --panel (the surface it sits on), NOT --invink. */
|
|
294
|
+
.ctxwt{font-size:11.5px;color:var(--ink2)}
|
|
295
|
+
.ctxwt .ctxbox{width:11px;height:11px;flex:0 0 11px;border-radius:3px;border:1px solid var(--ink3);
|
|
296
|
+
display:inline-block;position:relative;transition:background .12s,border-color .12s}
|
|
297
|
+
.ctxwt[aria-checked="true"]{color:var(--ink2)}
|
|
298
|
+
.ctxwt[aria-checked="true"] .ctxbox{background:var(--ink);border-color:var(--ink)}
|
|
299
|
+
.ctxwt[aria-checked="true"] .ctxbox::after{content:"";position:absolute;left:3px;top:0;width:3px;height:7px;
|
|
300
|
+
border:solid var(--panel);border-width:0 1.6px 1.6px 0;transform:rotate(42deg)}
|
|
301
|
+
.ctxmenu{min-width:230px;max-width:420px}
|
|
302
|
+
/* The folder list is galda's own screen (Claude has none — it opens ONE folder; galda
|
|
303
|
+
moves between repos). The one thing that actually goes wrong is that names alone look
|
|
304
|
+
identical (~/agent-manager vs ~/am-wt-kanban/agent-manager), so every row carries the
|
|
305
|
+
path underneath. Same container as every other menu — .appmenu/.appopt (§A2). */
|
|
306
|
+
.ctxmenu .appopt{height:auto;flex-direction:column;align-items:stretch;gap:1px;padding:6px 8px;
|
|
307
|
+
font-family:var(--ui);font-size:12px}
|
|
308
|
+
.ctxmenu .appopt .nm{color:var(--ink);font-size:12px;line-height:1.35}
|
|
309
|
+
.ctxmenu .appopt:not(.active) .nm{color:var(--ink2)}
|
|
310
|
+
/* direction:rtl keeps the END of the path (where repos differ) and drops the head.
|
|
311
|
+
The row text starts with a LRM — an RLO would render the path itself reversed. */
|
|
312
|
+
.ctxmenu .appopt .pt{font-family:var(--mono);font-size:10px;color:var(--ink3);line-height:1.4;
|
|
313
|
+
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left;unicode-bidi:plaintext}
|
|
314
|
+
.ctxmenu .ctxpath{display:flex;gap:5px;padding:5px 4px 2px;border-top:1px solid var(--hair);margin-top:3px}
|
|
315
|
+
.ctxmenu .ctxpath input{flex:1;min-width:0;height:26px;padding:0 8px;border-radius:6px;border:1px solid var(--hair2);
|
|
316
|
+
background:var(--chipbg);color:var(--ink);font-family:var(--mono);font-size:11px;outline:none}
|
|
317
|
+
.ctxmenu .ctxpath input:focus{border-color:var(--blue)}
|
|
318
|
+
.ctxmenu .ctxmsg{padding:2px 5px 3px;font-size:11px;color:var(--ink3)}
|
|
319
|
+
.ctxmenu .ctxmsg:empty{display:none}
|
|
320
|
+
.ctxmenu .ctxmsg.bad{color:var(--danger)}
|
|
270
321
|
.appopt{display:flex;align-items:center;gap:7px;height:28px;padding:0 8px;border:0;border-radius:6px;background:transparent;
|
|
271
322
|
color:var(--ink2);font:inherit;font-size:12px;cursor:pointer;text-align:left;width:100%}
|
|
272
323
|
.appopt:hover{background:rgba(var(--tint),.06);color:var(--ink)}
|
|
@@ -408,6 +459,14 @@
|
|
|
408
459
|
.qansrow{display:flex;gap:7px;align-items:center;margin-top:8px}
|
|
409
460
|
.qansrow input{flex:1;background:var(--glass);border:1px solid var(--hair2);border-radius:8px;color:var(--ink);font-size:12.5px;padding:6px 10px;outline:none}
|
|
410
461
|
.qsub{font-size:10.5px;color:var(--ink3);margin-top:7px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
462
|
+
/* The one line under a reply box: what the AI asked back, or the answer it gave.
|
|
463
|
+
Same part as .qsub, but an answer is a sentence — it must wrap, not ellipsis. */
|
|
464
|
+
/* The reply box's own short thread (CDO HANDOFF-REPLY-CONVERSATION §2/§5). It uses
|
|
465
|
+
the card's .txr You/Claude rows, so it needs .qsub's single-line truncation OFF —
|
|
466
|
+
scoped here, since every other .qsub stays a one-liner. */
|
|
467
|
+
.replynote{white-space:normal;overflow:visible;margin-top:14px;padding-top:12px;border-top:1px solid var(--hair)}
|
|
468
|
+
.replynote .qchips{margin-top:6px}
|
|
469
|
+
.replynote .txr.note .m{color:var(--ink3)}
|
|
411
470
|
.ob-unsent{color:rgb(var(--danger-rgb));cursor:pointer}
|
|
412
471
|
[data-oresend]{cursor:pointer}
|
|
413
472
|
.chip2{font-family:var(--mono);font-size:9.5px;letter-spacing:.05em;padding:2px 7px;border-radius:6px;border:1px solid var(--hair2);color:var(--ink3);flex:0 0 auto}
|
|
@@ -2028,6 +2087,20 @@
|
|
|
2028
2087
|
/* "Open it" is waiting on a real process to boot (up to ~15s), so the chip says so
|
|
2029
2088
|
rather than looking dead. Dim + no pointer, no spinner — §5.5 薄く・主張しない. */
|
|
2030
2089
|
.redesign .mx.mwait{opacity:.55;pointer-events:none}
|
|
2090
|
+
/* the AI's own screenshots: full width, own aspect, hairline edge (§5.5 縁は1px以下).
|
|
2091
|
+
No grid, no crop — one shot is often a comparison sheet and dies when shrunk. */
|
|
2092
|
+
.redesign .rshot{margin:0 0 16px}
|
|
2093
|
+
.redesign .rshot:last-child{margin-bottom:0}
|
|
2094
|
+
.redesign .rshot img{display:block;width:100%;height:auto;border-radius:10px;border:1px solid var(--hair)}
|
|
2095
|
+
.redesign .rshot figcaption{margin-top:6px;font-family:var(--mono);font-size:11px;color:var(--ink3)}
|
|
2096
|
+
/* before/after pair + the red box over what actually changed (pixel-diffed here from
|
|
2097
|
+
the worker's own two shots — no box appears unless the pixels really differ) */
|
|
2098
|
+
.redesign .beforeafter .proofimg{width:100%;height:auto;max-height:none;object-fit:initial;display:block;border-radius:8px;border:1px solid var(--hair)}
|
|
2099
|
+
.redesign .ba-imgwrap{position:relative}
|
|
2100
|
+
.redesign .ba-imgwrap .proofimg{margin-top:0}
|
|
2101
|
+
.redesign .ba-box{position:absolute;border:1.5px solid var(--danger);border-radius:2px;pointer-events:none}
|
|
2102
|
+
.redesign .ba-zoomhd{margin:14px 0 8px;font-family:var(--mono);font-size:11px;letter-spacing:.04em;text-transform:uppercase;color:var(--ink3)}
|
|
2103
|
+
.redesign .ba-zoomimg{display:block;width:100%;height:auto;border-radius:8px;border:1px solid var(--hair)}
|
|
2031
2104
|
.redesign .rfiles{margin:12px 0 0;font-family:var(--mono);font-size:12px;color:var(--ink2);line-height:1.75}
|
|
2032
2105
|
.redesign .rfiles .a{color:#3fd07a;font-style:normal}.redesign .rfiles .d{color:#ff7b72;font-style:normal}
|
|
2033
2106
|
/* Request accordion (spec §3) — chevron › rotate 90, body indented, no left rule */
|
|
@@ -2100,22 +2173,42 @@
|
|
|
2100
2173
|
in at the bottom of the opened body. */
|
|
2101
2174
|
.redesign .ract{margin-top:24px !important}
|
|
2102
2175
|
.redesign .ract>.rsec-h{margin-bottom:10px !important}
|
|
2103
|
-
.redesign .lacc>summary{list-style:none;cursor:pointer;display:flex;gap:
|
|
2176
|
+
.redesign .lacc>summary{list-style:none;cursor:pointer;display:flex;gap:14px;align-items:baseline}
|
|
2104
2177
|
.redesign .lacc>summary::-webkit-details-marker{display:none}
|
|
2105
|
-
.redesign .lacc .ltog{flex:0 0 auto;color:var(--
|
|
2178
|
+
.redesign .lacc .ltog{flex:0 0 auto;color:var(--ink2);font-style:normal;font-size:1.15em;line-height:1.25;transition:transform .14s}
|
|
2106
2179
|
.redesign .lacc[open]>summary .ltog{transform:rotate(90deg)}
|
|
2107
2180
|
.redesign .lacc .lfirst{flex:1;min-width:0}
|
|
2108
2181
|
.redesign .lacc .lbody{margin-top:12px;padding-left:18px}
|
|
2109
2182
|
/* transcript rows — Linear-clean, borderless (You / tool mono / res green / Claude) */
|
|
2110
2183
|
.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)
|
|
2184
|
+
.redesign .txr{display:flex;gap:12px;padding:5px 0;font-size:13px;line-height:1.55;align-items:baseline}
|
|
2185
|
+
.redesign .txr .w{flex:0 0 46px;font-size:11px;color:var(--ink3)}
|
|
2113
2186
|
.redesign .txr.you .w,.redesign .txr.cl .w{color:var(--ink);font-weight:600}
|
|
2114
2187
|
.redesign .txr .m{flex:1;min-width:0;color:var(--ink2)}
|
|
2115
2188
|
.redesign .txr.cl .m{color:var(--ink)}
|
|
2116
2189
|
.redesign .txr.tool .m{color:var(--ink2);font-family:var(--mono);font-size:12px}
|
|
2117
2190
|
.redesign .txr.tool .m b{color:var(--ink);font-weight:600}
|
|
2118
2191
|
.redesign .txr.res .m{font-family:var(--mono);font-size:12px;color:var(--green)}
|
|
2192
|
+
/* Manager's own progress notes inside the implementation log: commentary, not results.
|
|
2193
|
+
Green would read as "passed" (§5.5 UIクロームは薄く・優しく・主張しない). */
|
|
2194
|
+
.redesign .txr.note .m{font-family:var(--mono);font-size:12px;color:var(--ink3)}
|
|
2195
|
+
/* Conversation (ONE-CONVERSATION §1.1): the same transcript rows, so several
|
|
2196
|
+
entrances read as ONE column — no second box, no bubbles (§1.5-1 器を増やさない).
|
|
2197
|
+
Where a line was said is a hairline-quiet label, never a chip: it is context for
|
|
2198
|
+
the sentence, not a category of its own (§5.5 薄い要素も見えるが騒がない). */
|
|
2199
|
+
/* The entrance is CONTEXT for a sentence, not a category of it — so it is not a column
|
|
2200
|
+
(CDO handoff 2026-07-23 §B1). As a column it repeated 'chat' three times in five rows
|
|
2201
|
+
and pushed the body 116px right, so the same card had two left edges for the same kind
|
|
2202
|
+
of row. At the end of the line, only when it changed, the body lines up with Activity
|
|
2203
|
+
and the one thing worth knowing survives. */
|
|
2204
|
+
.redesign .txr .cvia{flex:0 0 auto;font-size:11px;color:var(--ink3);white-space:nowrap;padding-top:1px}
|
|
2205
|
+
/* "+N more" borrows the log's own quiet row (mono, --ink3) rather than inventing a
|
|
2206
|
+
control: nothing was summarised away, so pressing it only stops hiding (§B3). */
|
|
2207
|
+
.redesign .txr.convmore{cursor:pointer}
|
|
2208
|
+
.redesign .txr.convmore .m{font-family:var(--mono);font-size:12px;color:var(--ink3)}
|
|
2209
|
+
.redesign .txr.convmore:hover .m{color:var(--ink2)}
|
|
2210
|
+
.redesign .rconv .lbody .rtx{max-width:760px}
|
|
2211
|
+
.redesign .txr.mgr .m{color:var(--ink3)}
|
|
2119
2212
|
/* Discussion (review back-and-forth) folded at the bottom of Activity's body */
|
|
2120
2213
|
.redesign .lbody .rthread{display:flex;flex-direction:column;gap:16px;margin:0}
|
|
2121
2214
|
.redesign .lbody .rmsg .who{font-size:12px;font-weight:600;color:var(--ink);margin-bottom:4px}
|
|
@@ -2783,6 +2876,26 @@
|
|
|
2783
2876
|
<div class="errbar" id="errbar"></div>
|
|
2784
2877
|
<div class="composer-wrap" id="composerWrap">
|
|
2785
2878
|
<div class="queue" id="queue"></div>
|
|
2879
|
+
<!-- Context row (Masa 2026-07-22: 「claude の表記に合わせて」). The Claude app
|
|
2880
|
+
puts separate rounded chips above the box — repo · branch|worktree · add —
|
|
2881
|
+
rather than one continuous strip, and the words are its words. `Local` is
|
|
2882
|
+
deliberately absent: Claude offers it because it also runs in the cloud,
|
|
2883
|
+
and a chip that cannot be switched is decoration (§1.5-3). -->
|
|
2884
|
+
<div class="ctxbar" id="ctxbar">
|
|
2885
|
+
<div class="ctxsel ctxchip" id="ctxdirsel">
|
|
2886
|
+
<button type="button" class="ctxseg" id="ctxdirbtn" title="Folder this project works in">
|
|
2887
|
+
<svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z"/></svg><b id="ctxdirnow"></b></button>
|
|
2888
|
+
<div class="appmenu ctxmenu" id="ctxdirmenu"></div>
|
|
2889
|
+
</div>
|
|
2890
|
+
<div class="ctxchip ctxbrgrp" id="ctxbrgrp">
|
|
2891
|
+
<span class="ctxseg ctxbranch" id="ctxbranch" title="Branch this folder is on">
|
|
2892
|
+
<svg viewBox="0 0 24 24"><path d="M6 3v12M6 15a3 3 0 100 6 3 3 0 000-6zM18 9a3 3 0 100-6 3 3 0 000 6zM18 9v3a4 4 0 01-4 4H8"/></svg><span id="ctxbranchname"></span></span>
|
|
2893
|
+
<span class="ctxdiv"></span>
|
|
2894
|
+
<button type="button" class="ctxseg ctxwt" id="ctxwt" role="checkbox" aria-checked="true"
|
|
2895
|
+
title="On: each goal works in its own copy, so this folder is never touched. Off: the work happens right here.">
|
|
2896
|
+
<i class="ctxbox"></i>worktree</button>
|
|
2897
|
+
</div>
|
|
2898
|
+
</div>
|
|
2786
2899
|
<!-- Conversation-first composer (SLICE 2, docs/HANDOFF-ONBOARDING-conversational.md):
|
|
2787
2900
|
a chat/help/settings message gets a quiet reply HERE instead of
|
|
2788
2901
|
becoming a To-Do. Reuses the .peraanswer surface; hidden until a
|
|
@@ -3163,7 +3276,7 @@ const savedTheme = (() => {
|
|
|
3163
3276
|
if (v !== s) localStorage.setItem('theme', v);
|
|
3164
3277
|
return v;
|
|
3165
3278
|
})();
|
|
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,
|
|
3279
|
+
const state = { repos: null, repoHome: '', /* GET /api/repos — the folders a project can point at (Context Bar) */ 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,
|
|
3167
3280
|
// per-message send overrides set via slash-commands (reset after each send,
|
|
3168
3281
|
// except model which is sticky in localStorage). palette = open command list.
|
|
3169
3282
|
msg: { mode: null, model: null, effort: null, skill: null, later: false, review: false },
|
|
@@ -3353,9 +3466,12 @@ function prioBadge(t){
|
|
|
3353
3466
|
|
|
3354
3467
|
// Mirrors engine/lib.mjs DEFAULT_REVIEW_DEFINITION — kept in sync manually,
|
|
3355
3468
|
// see the comment on DEFAULT_WORKFLOW_COLUMNS above.
|
|
3469
|
+
// `requirePR` and `prSections` are deliberately absent: the engine stores them
|
|
3470
|
+
// but no code has ever read them, so the switches that wrote them are gone
|
|
3471
|
+
// (2026-07-22). The engine-side fields go in a follow-up; nothing here reads
|
|
3472
|
+
// them in the meantime.
|
|
3356
3473
|
const DEFAULT_REVIEW_DEFINITION = {
|
|
3357
|
-
|
|
3358
|
-
prSections: { summary: true, whatChanged: true, howToReview: true, tests: true, screenshots: true, risk: true },
|
|
3474
|
+
requireVerifyPass: false, description: '', defaultWantsPR: false, language: 'auto',
|
|
3359
3475
|
reviewCard: { screenshots: true, beforeAfter: false },
|
|
3360
3476
|
};
|
|
3361
3477
|
// Shown (faint) under Review when no definition is set yet, so it's obvious the
|
|
@@ -3758,11 +3874,105 @@ function proofMediaHtml(taskId, proof, altLabel){
|
|
|
3758
3874
|
// us, not by the work: the "before" shot came from the same photo-staging pass that P1
|
|
3759
3875
|
// removed on the engine side (#228), and in practice it never existed at all, so every
|
|
3760
3876
|
// card fell through to the single shot anyway. Callers now ask for that shot directly.
|
|
3761
|
-
// (
|
|
3762
|
-
//
|
|
3763
|
-
//
|
|
3764
|
-
//
|
|
3765
|
-
//
|
|
3877
|
+
// Red box (restored 2026-07-22 on Masa's ask). This machinery was deleted in #230 with
|
|
3878
|
+
// the staged before/after pair, because the 'before' was a photo WE took of a screen
|
|
3879
|
+
// that had nothing to do with the change. It comes back on a different footing: the two
|
|
3880
|
+
// images are both the worker's own shots, taken by the agent that did the work, and the
|
|
3881
|
+
// box is computed here from those two PNGs by pixel comparison — nothing is invented, a
|
|
3882
|
+
// box only appears when the pixels really differ in one region.
|
|
3883
|
+
// minCoverage lowered 2026-07-22: the old 0.05% floor was tuned on a 640x200 proof pair.
|
|
3884
|
+
// A real worker shot is a whole 900-1440px page, where a genuine change (a dot going
|
|
3885
|
+
// green, a label swapping) is well under that floor and was being thrown away as noise.
|
|
3886
|
+
// 30 pixels differing by more than `threshold` is a change someone made on purpose;
|
|
3887
|
+
// compression noise does not clear a 24-point RGB delta.
|
|
3888
|
+
function computeDiffBox(beforeImg, afterImg, { threshold = 24, minPixels = 30, maxCoverage = 0.7, pad = 0.03 } = {}) {
|
|
3889
|
+
const w = beforeImg.naturalWidth, h = beforeImg.naturalHeight;
|
|
3890
|
+
if (w !== afterImg.naturalWidth || h !== afterImg.naturalHeight) return null; // different-shaped images: don't guess
|
|
3891
|
+
const c1 = document.createElement('canvas'); c1.width = w; c1.height = h;
|
|
3892
|
+
const c2 = document.createElement('canvas'); c2.width = w; c2.height = h;
|
|
3893
|
+
c1.getContext('2d').drawImage(beforeImg, 0, 0, w, h);
|
|
3894
|
+
c2.getContext('2d').drawImage(afterImg, 0, 0, w, h);
|
|
3895
|
+
const d1 = c1.getContext('2d').getImageData(0, 0, w, h).data;
|
|
3896
|
+
const d2 = c2.getContext('2d').getImageData(0, 0, w, h).data;
|
|
3897
|
+
let minX = w, minY = h, maxX = -1, maxY = -1, diffPx = 0;
|
|
3898
|
+
for (let y = 0; y < h; y++) {
|
|
3899
|
+
for (let x = 0; x < w; x++) {
|
|
3900
|
+
const i = (y * w + x) * 4;
|
|
3901
|
+
const d = Math.abs(d1[i] - d2[i]) + Math.abs(d1[i + 1] - d2[i + 1]) + Math.abs(d1[i + 2] - d2[i + 2]);
|
|
3902
|
+
if (d > threshold) {
|
|
3903
|
+
diffPx++;
|
|
3904
|
+
if (x < minX) minX = x; if (x > maxX) maxX = x;
|
|
3905
|
+
if (y < minY) minY = y; if (y > maxY) maxY = y;
|
|
3906
|
+
}
|
|
3907
|
+
}
|
|
3908
|
+
}
|
|
3909
|
+
const total = w * h;
|
|
3910
|
+
if (maxX < 0 || diffPx < minPixels) return null; // no real difference — don't box a no-op
|
|
3911
|
+
if (diffPx / total > maxCoverage) return null; // diff too diffuse to be "a spot" — a full redesign shouldn't get a red box either
|
|
3912
|
+
const padPx = Math.round(Math.max(w, h) * pad);
|
|
3913
|
+
minX = Math.max(0, minX - padPx); minY = Math.max(0, minY - padPx);
|
|
3914
|
+
maxX = Math.min(w, maxX + padPx); maxY = Math.min(h, maxY + padPx);
|
|
3915
|
+
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
|
3916
|
+
}
|
|
3917
|
+
function baLoadImage(src){
|
|
3918
|
+
return new Promise((resolve, reject) => {
|
|
3919
|
+
const im = new Image();
|
|
3920
|
+
im.onload = () => resolve(im);
|
|
3921
|
+
im.onerror = () => reject(new Error(`image failed to load: ${src}`));
|
|
3922
|
+
im.src = src;
|
|
3923
|
+
});
|
|
3924
|
+
}
|
|
3925
|
+
// Crop {x,y,w,h} out of a decoded image and scale it up into a small inset — same
|
|
3926
|
+
// aspect ratio as the box, never stretched. Magnification is capped at 6x so a
|
|
3927
|
+
// near-single-pixel box doesn't blow up into mush.
|
|
3928
|
+
function baZoomDataUrl(img, box, maxW = 420, maxH = 200){
|
|
3929
|
+
const scale = Math.min(maxW / box.w, maxH / box.h, 6);
|
|
3930
|
+
const outW = Math.max(1, Math.round(box.w * scale));
|
|
3931
|
+
const outH = Math.max(1, Math.round(box.h * scale));
|
|
3932
|
+
const c = document.createElement('canvas'); c.width = outW; c.height = outH;
|
|
3933
|
+
c.getContext('2d').drawImage(img, box.x, box.y, box.w, box.h, 0, 0, outW, outH);
|
|
3934
|
+
return c.toDataURL('image/png');
|
|
3935
|
+
}
|
|
3936
|
+
// A before/after pair exists only when the AI SAYS two of its shots are the same view
|
|
3937
|
+
// before and after (task.run.compare = [beforeName, afterName]). Masa 2026-07-22: an
|
|
3938
|
+
// earlier version of this guessed the pair from file names containing "before"/"after",
|
|
3939
|
+
// which is this layer inventing a relationship it cannot know — the same mistake as the
|
|
3940
|
+
// intake AI writing the pass condition. A request for three options has no "after"; only
|
|
3941
|
+
// the agent that did the work knows which images relate, so only it can say so.
|
|
3942
|
+
//
|
|
3943
|
+
// Nothing declares `compare` yet (the engine keeps cmd/url/note/check), so no card draws
|
|
3944
|
+
// a box today. The machinery below stays wired: the day the worker can declare a pair,
|
|
3945
|
+
// it lights up with no further app change.
|
|
3946
|
+
function saShotPair(shots, declared){
|
|
3947
|
+
const list = shots ?? [];
|
|
3948
|
+
const pair = Array.isArray(declared) ? declared : null;
|
|
3949
|
+
if (!pair || pair.length !== 2) return null;
|
|
3950
|
+
const find = (n) => list.find((s) => s.name === n || String(s.url || '').endsWith(String(n)));
|
|
3951
|
+
const before = find(pair[0]), after = find(pair[1]);
|
|
3952
|
+
return before && after && before !== after ? { before, after } : null;
|
|
3953
|
+
}
|
|
3954
|
+
function saPlainPairHtml(pair){
|
|
3955
|
+
const col = (sh, label) => `<div class="ba-col"><span class="balabel">${label}</span><img class="proofimg" src="${esc(sh.url)}" alt="${esc(label)}" loading="lazy"></div>`;
|
|
3956
|
+
return `<div class="beforeafter">${col(pair.before, 'Before')}${col(pair.after, 'After')}</div>`;
|
|
3957
|
+
}
|
|
3958
|
+
// Renders the pair with the changed region boxed on both, plus a cropped enlargement.
|
|
3959
|
+
// Returns null on every fallback (not two images, load failure, no clean box, different
|
|
3960
|
+
// sizes) so the caller keeps showing the plain shots — never a guessed box.
|
|
3961
|
+
async function shotsBoxedHtml(before, after){
|
|
3962
|
+
if (!before?.url || !after?.url) return null;
|
|
3963
|
+
let bImg, aImg;
|
|
3964
|
+
try { [bImg, aImg] = await Promise.all([baLoadImage(before.url), baLoadImage(after.url)]); }
|
|
3965
|
+
catch { return null; }
|
|
3966
|
+
const box = computeDiffBox(bImg, aImg);
|
|
3967
|
+
if (!box) return null;
|
|
3968
|
+
const w = bImg.naturalWidth, h = bImg.naturalHeight;
|
|
3969
|
+
const st = `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)}%`;
|
|
3970
|
+
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()}" loading="lazy"><div class="ba-box" style="${st}"></div></div></div>`;
|
|
3971
|
+
const zoom = (dataUrl, label) => `<div class="ba-col"><img class="ba-zoomimg" src="${dataUrl}" alt="zoomed ${label}"></div>`;
|
|
3972
|
+
return `<div class="beforeafter">${frame(bImg, 'Before')}${frame(aImg, 'After')}</div>`
|
|
3973
|
+
+ `<div class="ba-zoomhd">Zoomed on the boxed area</div>`
|
|
3974
|
+
+ `<div class="beforeafter ba-zoomrow">${zoom(baZoomDataUrl(bImg, box), 'before')}${zoom(baZoomDataUrl(aImg, box), 'after')}</div>`;
|
|
3975
|
+
}
|
|
3766
3976
|
// swap the still for the real video only on demand, one at a time
|
|
3767
3977
|
document.addEventListener('click', (e) => {
|
|
3768
3978
|
const w = e.target.closest('.proofwrap');
|
|
@@ -4491,9 +4701,14 @@ function renderTasks(){
|
|
|
4491
4701
|
// the rework task ("reworking: <goal>").
|
|
4492
4702
|
const sendQReply = async (gid, inp) => {
|
|
4493
4703
|
const text = inp.value.trim(); if (!text) return;
|
|
4494
|
-
|
|
4495
|
-
|
|
4496
|
-
|
|
4704
|
+
try {
|
|
4705
|
+
const body = await replyToGoal(gid, text, `[data-qreplyinput="${gid}"]`);
|
|
4706
|
+
if (!body) return; // asked, and nobody chose — leave the words in the box
|
|
4707
|
+
inp.value = '';
|
|
4708
|
+
// A question keeps the row open: the answer lands under this very box.
|
|
4709
|
+
if (body.outcome !== 'answer') state.qreplyOpen.delete(Number(gid));
|
|
4710
|
+
await refresh();
|
|
4711
|
+
} catch { showErr('送信に失敗しました'); }
|
|
4497
4712
|
};
|
|
4498
4713
|
for (const b of $('tasklist').querySelectorAll('[data-qreplysend]')) {
|
|
4499
4714
|
b.onclick = (e) => { e.stopPropagation(); sendQReply(b.dataset.qreplysend, $('tasklist').querySelector(`[data-qreplyinput="${b.dataset.qreplysend}"]`)); };
|
|
@@ -4782,13 +4997,12 @@ function renderAttach(){
|
|
|
4782
4997
|
function openWfEditor(){
|
|
4783
4998
|
state.wfDraft = columnsFor(state.active).map((c) => ({ ...c }));
|
|
4784
4999
|
const def = reviewDefinitionFor(state.active);
|
|
4785
|
-
//
|
|
4786
|
-
//
|
|
5000
|
+
// reviewCard is a nested object — a shallow `{ ...def }` would share it by
|
|
5001
|
+
// reference, so toggling one checkbox here would mutate
|
|
4787
5002
|
// DEFAULT_REVIEW_DEFINITION (or another project's cached definition)
|
|
4788
|
-
// instead of just this draft. Copy
|
|
5003
|
+
// instead of just this draft. Copy it one level deeper.
|
|
4789
5004
|
state.rdDraft = {
|
|
4790
5005
|
...def,
|
|
4791
|
-
prSections: { ...DEFAULT_REVIEW_DEFINITION.prSections, ...(def.prSections ?? {}) },
|
|
4792
5006
|
reviewCard: reviewCardConfigFor(state.active),
|
|
4793
5007
|
};
|
|
4794
5008
|
$('wferr').style.display = 'none';
|
|
@@ -4801,35 +5015,12 @@ function renderRdEditor(){
|
|
|
4801
5015
|
<label class="rdrow"><input type="checkbox" id="rdDefaultWantsPR"${state.rdDraft.defaultWantsPR ? ' checked' : ''}>
|
|
4802
5016
|
<span><span class="rdlabel">Open a GitHub pull request by default</span>
|
|
4803
5017
|
<span class="rdhint">New goals left on "Auto" open a PR unless their text says otherwise. Per-goal PR/No PR always overrides this.</span></span></label>
|
|
4804
|
-
<label class="rdrow"><input type="checkbox" id="rdRequirePR"${state.rdDraft.requirePR ? ' checked' : ''}>
|
|
4805
|
-
<span><span class="rdlabel">Requires a pull request</span>
|
|
4806
|
-
<span class="rdhint">A goal that asked for a PR always lands in Review, even if PR creation failed.</span></span></label>
|
|
4807
5018
|
<label class="rdrow"><input type="checkbox" id="rdRequireVerify"${state.rdDraft.requireVerifyPass ? ' checked' : ''}>
|
|
4808
5019
|
<span><span class="rdlabel">Requires an independent verify pass</span>
|
|
4809
5020
|
<span class="rdhint">Any task whose headless-browser check didn't pass (or never ran) forces Review too.</span></span></label>
|
|
4810
5021
|
<label class="rdrow rddesc"><span><span class="rdlabel">Review instructions</span>
|
|
4811
|
-
<span class="rdhint">
|
|
4812
|
-
<textarea class="rdtext" id="rdDescription" maxlength="500" rows="2" placeholder="
|
|
4813
|
-
|
|
4814
|
-
<div class="rdsub">PR本文に含める項目 / PR body sections</div>
|
|
4815
|
-
<label class="rdrow"><input type="checkbox" id="rdPrSummary"${state.rdDraft.prSections.summary ? ' checked' : ''}>
|
|
4816
|
-
<span><span class="rdlabel">Summary</span>
|
|
4817
|
-
<span class="rdhint">ゴールの目的(Goal本文)をPRに含める。</span></span></label>
|
|
4818
|
-
<label class="rdrow"><input type="checkbox" id="rdPrWhatChanged"${state.rdDraft.prSections.whatChanged ? ' checked' : ''}>
|
|
4819
|
-
<span><span class="rdlabel">What changed</span>
|
|
4820
|
-
<span class="rdhint">変更ファイルの一覧をPRに含める。</span></span></label>
|
|
4821
|
-
<label class="rdrow"><input type="checkbox" id="rdPrHowToReview"${state.rdDraft.prSections.howToReview ? ' checked' : ''}>
|
|
4822
|
-
<span><span class="rdlabel">How to review</span>
|
|
4823
|
-
<span class="rdhint">上のレビュー指示(Review instructions)をPR本文にも載せる。</span></span></label>
|
|
4824
|
-
<label class="rdrow"><input type="checkbox" id="rdPrTests"${state.rdDraft.prSections.tests ? ' checked' : ''}>
|
|
4825
|
-
<span><span class="rdlabel">Tests</span>
|
|
4826
|
-
<span class="rdhint">Managerが実行したテスト結果をPRに含める。</span></span></label>
|
|
4827
|
-
<label class="rdrow"><input type="checkbox" id="rdPrScreenshots"${state.rdDraft.prSections.screenshots ? ' checked' : ''}>
|
|
4828
|
-
<span><span class="rdlabel">Screenshots</span>
|
|
4829
|
-
<span class="rdhint">証跡(スクショ/GIF)へのリンクをPRに含める。</span></span></label>
|
|
4830
|
-
<label class="rdrow"><input type="checkbox" id="rdPrRisk"${state.rdDraft.prSections.risk ? ' checked' : ''}>
|
|
4831
|
-
<span><span class="rdlabel">Risk</span>
|
|
4832
|
-
<span class="rdhint">注意が必要そうな変更範囲を一言添える。</span></span></label>
|
|
5022
|
+
<span class="rdhint">Goes into the pull request body, so whoever reviews it knows what to look for. The worker doesn't read this while it works — instructions for the AI belong in your project's CLAUDE.md / AGENTS.md.</span>
|
|
5023
|
+
<textarea class="rdtext" id="rdDescription" maxlength="500" rows="2" placeholder="例:「変更前後のスクショを見比べて、余白が揃っているか確認してください」など、レビューする人に伝えたいこと">${esc(state.rdDraft.description ?? '')}</textarea></span></label>
|
|
4833
5024
|
|
|
4834
5025
|
<div class="rdsub">レビューカードに出す項目 / Review card fields</div>
|
|
4835
5026
|
<label class="rdrow"><input type="checkbox" id="rdCardScreenshots"${state.rdDraft.reviewCard.screenshots ? ' checked' : ''}>
|
|
@@ -4845,15 +5036,8 @@ function renderRdEditor(){
|
|
|
4845
5036
|
<span><span class="rdlabel">Review language</span>
|
|
4846
5037
|
<span class="rdhint">レビュー要約(確認/変更)などManagerが生成する文言の言語。</span></span></label>`;
|
|
4847
5038
|
$('rdDefaultWantsPR').addEventListener('change', (e) => { state.rdDraft.defaultWantsPR = e.target.checked; });
|
|
4848
|
-
$('rdRequirePR').addEventListener('change', (e) => { state.rdDraft.requirePR = e.target.checked; });
|
|
4849
5039
|
$('rdRequireVerify').addEventListener('change', (e) => { state.rdDraft.requireVerifyPass = e.target.checked; });
|
|
4850
5040
|
$('rdDescription').addEventListener('input', (e) => { state.rdDraft.description = e.target.value; });
|
|
4851
|
-
$('rdPrSummary').addEventListener('change', (e) => { state.rdDraft.prSections.summary = e.target.checked; });
|
|
4852
|
-
$('rdPrWhatChanged').addEventListener('change', (e) => { state.rdDraft.prSections.whatChanged = e.target.checked; });
|
|
4853
|
-
$('rdPrHowToReview').addEventListener('change', (e) => { state.rdDraft.prSections.howToReview = e.target.checked; });
|
|
4854
|
-
$('rdPrTests').addEventListener('change', (e) => { state.rdDraft.prSections.tests = e.target.checked; });
|
|
4855
|
-
$('rdPrScreenshots').addEventListener('change', (e) => { state.rdDraft.prSections.screenshots = e.target.checked; });
|
|
4856
|
-
$('rdPrRisk').addEventListener('change', (e) => { state.rdDraft.prSections.risk = e.target.checked; });
|
|
4857
5041
|
$('rdCardScreenshots').addEventListener('change', (e) => { state.rdDraft.reviewCard.screenshots = e.target.checked; });
|
|
4858
5042
|
$('rdLanguage').addEventListener('change', (e) => { state.rdDraft.language = e.target.value; });
|
|
4859
5043
|
}
|
|
@@ -5345,6 +5529,171 @@ function renderReviewAttach(){
|
|
|
5345
5529
|
bar.innerHTML = reviewAttach.map((a, i) => `<span class="reqthumb"><img src="${esc(a.url)}" alt=""><button type="button" class="rm" data-ri="${i}" aria-label="Remove">×</button></span>`).join('');
|
|
5346
5530
|
for (const b of bar.querySelectorAll('.rm')) b.onclick = () => { reviewAttach.splice(Number(b.dataset.ri), 1); renderReviewAttach(); };
|
|
5347
5531
|
}
|
|
5532
|
+
// 返事は "auto" で送る (ONE-CONVERSATION §1.3/§1.4)。
|
|
5533
|
+
//
|
|
5534
|
+
// 行き先を決めるのは AI 本人で、この層は言われたとおりに配線するだけ。今までは中身に
|
|
5535
|
+
// 関わらず全部「直して」で、「なんでこうしたの?」と聞いただけでゴールが Doing に戻り
|
|
5536
|
+
// ワーカーが走っていた。人はモードを普段選ばない——AI が迷った時だけ選ぶ。
|
|
5537
|
+
const REPLY_CHOICE_LABEL = { continue: 'Fix this', rescope: 'Rethink it', split: 'Separate ticket', answer: 'Just a question' };
|
|
5538
|
+
// The one waiting line's two labels. Local to the reply note on purpose — STATUS_LABEL
|
|
5539
|
+
// is the task/workflow vocabulary and this is neither (CDO §5).
|
|
5540
|
+
const REPLY_WAIT_ROUTING = 'Thinking…';
|
|
5541
|
+
const REPLY_WAIT_ANSWERING = 'Answering…';
|
|
5542
|
+
async function postReply(goalId, text, outcome = 'auto'){
|
|
5543
|
+
const r = await fetch(withKey(`/api/goals/${goalId}/dismiss`), { method: 'POST',
|
|
5544
|
+
headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, outcome }) });
|
|
5545
|
+
if (!r.ok) throw new Error();
|
|
5546
|
+
return r.json();
|
|
5547
|
+
}
|
|
5548
|
+
// One line under whichever reply box was used. It is held in a variable and
|
|
5549
|
+
// PAINTED after every render, because a reply writes to the goal, which arrives
|
|
5550
|
+
// back over SSE, which rebuilds the list — a node appended straight to the DOM
|
|
5551
|
+
// disappears a second later (and an answer nobody sees is the same as no answer).
|
|
5552
|
+
let replyNoteState = null; // { anchorSel, you, phase, html, resolve? }
|
|
5553
|
+
function paintReplyNote(){
|
|
5554
|
+
for (const stale of document.querySelectorAll('.replynote')) stale.remove();
|
|
5555
|
+
paintSettledThreads();
|
|
5556
|
+
if (!replyNoteState) return;
|
|
5557
|
+
const anchor = document.querySelector(replyNoteState.anchorSel);
|
|
5558
|
+
if (!anchor) return;
|
|
5559
|
+
for (const stale of document.querySelectorAll('.replynote')) stale.remove();
|
|
5560
|
+
const el = document.createElement('div');
|
|
5561
|
+
el.className = 'qsub replynote';
|
|
5562
|
+
el.innerHTML = replyNoteState.html;
|
|
5563
|
+
// BELOW the reply box, never beside it (§1.4). The review card's box lives in a
|
|
5564
|
+
// flex row of buttons — appending inside it puts the choices next to the send
|
|
5565
|
+
// arrow, on top of it at narrow widths.
|
|
5566
|
+
const row = anchor.closest('.sa-act') ?? anchor.parentElement;
|
|
5567
|
+
if (row?.parentElement) row.after(el); else row?.appendChild(el);
|
|
5568
|
+
for (const b of el.querySelectorAll('[data-rc]')) b.onclick = () => {
|
|
5569
|
+
const pick = b.dataset.rc, done = replyNoteState?.resolve;
|
|
5570
|
+
replyNoteState = null; paintReplyNote(); done?.(pick);
|
|
5571
|
+
};
|
|
5572
|
+
}
|
|
5573
|
+
// Every open card with past review turns shows them, whether or not a reply is in
|
|
5574
|
+
// flight — this is what makes "close it, come back tomorrow" work (CDO §3.1).
|
|
5575
|
+
function paintSettledThreads(){
|
|
5576
|
+
for (const inp of document.querySelectorAll('#seeall .sa-chat[data-goal]')) {
|
|
5577
|
+
const goalId = Number(inp.dataset.goal);
|
|
5578
|
+
loadReplyThread(goalId);
|
|
5579
|
+
if (replyNoteState?.goalId === goalId) continue; // the live note draws its own history
|
|
5580
|
+
const html = replyPastHtmlFor(goalId);
|
|
5581
|
+
if (!html) continue;
|
|
5582
|
+
const row = inp.closest('.sa-act') ?? inp.parentElement;
|
|
5583
|
+
if (!row?.parentElement) continue;
|
|
5584
|
+
const el = document.createElement('div');
|
|
5585
|
+
el.className = 'qsub replynote';
|
|
5586
|
+
el.innerHTML = html;
|
|
5587
|
+
row.after(el);
|
|
5588
|
+
}
|
|
5589
|
+
}
|
|
5590
|
+
function setReplyNote(anchorSel, html, resolve){
|
|
5591
|
+
replyNoteState = anchorSel ? { ...replyNoteState, anchorSel, html, resolve } : null;
|
|
5592
|
+
paintReplyNote();
|
|
5593
|
+
}
|
|
5594
|
+
// Past turns of this card's reply thread, by goal id. The exchange is not a session
|
|
5595
|
+
// thing: every message this flow writes is on the goal (`goal.messages`, via:'review'),
|
|
5596
|
+
// so reopening the card next week must show it exactly as it settled (CDO §3.1).
|
|
5597
|
+
// Fetched once per goal when the card renders; the live state machine in
|
|
5598
|
+
// replyNoteState only ever covers a reply that is still in flight right now.
|
|
5599
|
+
const replyThread = {}; // goalId -> [{from, via, text}]
|
|
5600
|
+
const replyThreadLoading = new Set();
|
|
5601
|
+
function loadReplyThread(goalId){
|
|
5602
|
+
if (replyThread[goalId] || replyThreadLoading.has(goalId)) return;
|
|
5603
|
+
replyThreadLoading.add(goalId);
|
|
5604
|
+
fetch(withKey(`/api/goals/${goalId}/messages`))
|
|
5605
|
+
.then((r) => (r.ok ? r.json() : { messages: [] }))
|
|
5606
|
+
.then((b) => {
|
|
5607
|
+
replyThread[goalId] = (b.messages ?? []).filter((m) => m.via === 'review');
|
|
5608
|
+
if (replyThread[goalId].length) saRender();
|
|
5609
|
+
})
|
|
5610
|
+
.catch(() => { replyThread[goalId] = []; })
|
|
5611
|
+
.finally(() => replyThreadLoading.delete(goalId));
|
|
5612
|
+
}
|
|
5613
|
+
// Settled history: no shimmer, nothing pending — this already happened.
|
|
5614
|
+
function replyPastHtml(){
|
|
5615
|
+
const goalId = replyNoteState?.goalId;
|
|
5616
|
+
return replyPastHtmlFor(goalId);
|
|
5617
|
+
}
|
|
5618
|
+
function replyPastHtmlFor(goalId){
|
|
5619
|
+
const msgs = goalId == null ? [] : (replyThread[goalId] ?? []);
|
|
5620
|
+
return msgs.map((m) => (m.from === 'you' ? replySaidHtml(m.text)
|
|
5621
|
+
: m.from === 'ai' ? replyAnsweredHtml(m.text)
|
|
5622
|
+
: replyNoteRowHtml(esc(m.text)))).join('');
|
|
5623
|
+
}
|
|
5624
|
+
|
|
5625
|
+
// The reply box's thread rows. NOT the centre feed's .cc-you/.cc-say: every rule for
|
|
5626
|
+
// that vocabulary is scoped to #fsRoot, and the review card lives in #seeall — the
|
|
5627
|
+
// markup rendered there came out as unstyled black text (CDO decision §1). The card
|
|
5628
|
+
// already draws You/Claude rows for Activity with `.txr`, correctly scoped and
|
|
5629
|
+
// weighted, so this reuses those instead of defining anything new.
|
|
5630
|
+
function replySaidHtml(you){
|
|
5631
|
+
return `<div class="txr you"><span class="w">You</span><span class="m">${esc(you)}</span></div>`;
|
|
5632
|
+
}
|
|
5633
|
+
// One indicator that changes its label — never a second spinner (CDO decision §2, Q2).
|
|
5634
|
+
function replyWaitHtml(label){
|
|
5635
|
+
return `<div class="txr note"><span class="w"></span><span class="m"><span class="shimmer">${esc(label)}</span></span></div>`;
|
|
5636
|
+
}
|
|
5637
|
+
function replyNoteRowHtml(body){
|
|
5638
|
+
return `<div class="txr note"><span class="w"></span><span class="m">${body}</span></div>`;
|
|
5639
|
+
}
|
|
5640
|
+
function replyAnsweredHtml(text){
|
|
5641
|
+
return `<div class="txr cl"><span class="w">Claude</span><span class="m">${esc(text)}</span></div>`;
|
|
5642
|
+
}
|
|
5643
|
+
// Sent, and nothing decided yet. Shown immediately — the wait is real (a routing
|
|
5644
|
+
// call, then the answer), so the honest thing is to say it is thinking, not to
|
|
5645
|
+
// pretend the reply landed somewhere it has not.
|
|
5646
|
+
function showReplySent(anchorSel, you, goalId){
|
|
5647
|
+
replyNoteState = { anchorSel, you, goalId, phase: 'sent' };
|
|
5648
|
+
setReplyNote(anchorSel, replyPastHtml() + replySaidHtml(you) + replyWaitHtml(REPLY_WAIT_ROUTING));
|
|
5649
|
+
}
|
|
5650
|
+
// AI said it cannot tell (§1.4): the goal has NOT been touched, so this asks and
|
|
5651
|
+
// sends again with the chosen destination. Leaving it unanswered changes nothing.
|
|
5652
|
+
function askReplyDestination(anchorSel, choices){
|
|
5653
|
+
return new Promise((resolve) => {
|
|
5654
|
+
const said = replyNoteState?.you ? replySaidHtml(replyNoteState.you) : '';
|
|
5655
|
+
// The You row stays above the chips so "this" has something to point at (CDO Q5).
|
|
5656
|
+
setReplyNote(anchorSel, replyPastHtml() + said + replyNoteRowHtml('Which one is this?')
|
|
5657
|
+
+ `<div class="qchips">${(choices || []).map((c) =>
|
|
5658
|
+
`<button type="button" class="qchip" data-rc="${esc(c)}">${esc(REPLY_CHOICE_LABEL[c] ?? c)}</button>`).join('')}</div>`, resolve);
|
|
5659
|
+
});
|
|
5660
|
+
}
|
|
5661
|
+
// answer (§1.3): nothing was built, so there is no task to watch — the reply comes
|
|
5662
|
+
// back as a message on the goal. Show it where the question was asked, so an answer
|
|
5663
|
+
// cannot arrive somewhere nobody is looking.
|
|
5664
|
+
async function showAnswerWhenItArrives(anchorSel, goalId, seen){
|
|
5665
|
+
const mine = () => replyPastHtml() + (replyNoteState?.you ? replySaidHtml(replyNoteState.you) : '');
|
|
5666
|
+
setReplyNote(anchorSel, mine() + replyWaitHtml(REPLY_WAIT_ANSWERING));
|
|
5667
|
+
for (let i = 0; i < 120; i++) {
|
|
5668
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
5669
|
+
let msgs = [];
|
|
5670
|
+
try { msgs = (await (await fetch(withKey(`/api/goals/${goalId}/messages`))).json()).messages ?? []; } catch { continue; }
|
|
5671
|
+
const said = msgs.slice(seen).find((m) => m.from === 'ai' || m.from === 'manager');
|
|
5672
|
+
if (said) {
|
|
5673
|
+
// It is history now — render it exactly as a reopened card will (CDO §3.1), and
|
|
5674
|
+
// keep it anchored where it was asked, so this works in the list's reply box too
|
|
5675
|
+
// (that one is not inside #seeall and would otherwise lose the answer).
|
|
5676
|
+
replyThread[goalId] = msgs.filter((m) => m.via === 'review');
|
|
5677
|
+
return setReplyNote(anchorSel, replyPastHtmlFor(goalId));
|
|
5678
|
+
}
|
|
5679
|
+
}
|
|
5680
|
+
setReplyNote(anchorSel, mine() + replyNoteRowHtml('No answer came back.'));
|
|
5681
|
+
}
|
|
5682
|
+
// The whole reply flow in one place: send as auto, ask if the AI was unsure, and
|
|
5683
|
+
// keep an answer visible. Returns the settled body, or null if the person cancelled.
|
|
5684
|
+
async function replyToGoal(goalId, text, anchorSel){
|
|
5685
|
+
const seen = state.goals.find((g) => g.id === Number(goalId))?.messageCount ?? 0;
|
|
5686
|
+
showReplySent(anchorSel, text, Number(goalId)); // before the round trip: the words stay, and it says it is thinking
|
|
5687
|
+
let body = await postReply(goalId, text, 'auto');
|
|
5688
|
+
if (body.outcome === 'unsure') {
|
|
5689
|
+
const picked = await askReplyDestination(anchorSel, body.choices);
|
|
5690
|
+
if (!picked) return null;
|
|
5691
|
+
body = await postReply(goalId, text, picked);
|
|
5692
|
+
}
|
|
5693
|
+
// seen + 1: the question itself was just recorded; the answer is the one after it.
|
|
5694
|
+
if (body.outcome === 'answer') showAnswerWhenItArrives(anchorSel, goalId, seen + 1);
|
|
5695
|
+
return body;
|
|
5696
|
+
}
|
|
5348
5697
|
async function sendReviewDismiss(text){
|
|
5349
5698
|
const goal = state.goals.find((g) => g.id === state.reviewOpenGoal);
|
|
5350
5699
|
if (!goal) return;
|
|
@@ -5355,15 +5704,15 @@ async function sendReviewDismiss(text){
|
|
|
5355
5704
|
? `${text}${text ? '\n\n' : ''}添付画像(workerはこのパスをReadで確認できます): ${imgs.map((a) => a.path).join(', ')}`
|
|
5356
5705
|
: text;
|
|
5357
5706
|
try {
|
|
5358
|
-
const
|
|
5359
|
-
|
|
5360
|
-
if (!r.ok) throw new Error();
|
|
5361
|
-
const body = await r.json();
|
|
5707
|
+
const body = await replyToGoal(goal.id, fullText, '#reviewFixInput');
|
|
5708
|
+
if (!body) return; // unsure, and nobody chose
|
|
5362
5709
|
upsert(state.goals, body.goal);
|
|
5363
5710
|
if (body.task) upsert(state.tasks, body.task);
|
|
5364
5711
|
$('reviewFixInput').value = '';
|
|
5365
5712
|
reviewAttach = []; renderReviewAttach();
|
|
5366
|
-
|
|
5713
|
+
// A question changed nothing, so closing the card would hide the answer that is
|
|
5714
|
+
// on its way. Only a reply that actually moved the goal closes it.
|
|
5715
|
+
if (body.outcome !== 'answer') closeReviewChecklist();
|
|
5367
5716
|
render();
|
|
5368
5717
|
} catch { showErr('Dismiss failed.'); }
|
|
5369
5718
|
}
|
|
@@ -5585,14 +5934,12 @@ async function sendReject(goalId){
|
|
|
5585
5934
|
async function sendDismiss(goalId, text){
|
|
5586
5935
|
$('gdApprove').disabled = true; $('gdDismiss').disabled = true; $('gdSend').disabled = true;
|
|
5587
5936
|
try {
|
|
5588
|
-
const
|
|
5589
|
-
|
|
5590
|
-
if (!r.ok) throw new Error();
|
|
5591
|
-
const body = await r.json();
|
|
5937
|
+
const body = await replyToGoal(goalId, text, '#gdRevisionInput');
|
|
5938
|
+
if (!body) { $('gdApprove').disabled = false; $('gdDismiss').disabled = false; $('gdSend').disabled = false; return; }
|
|
5592
5939
|
upsert(state.goals, body.goal);
|
|
5593
5940
|
upsert(state.tasks, body.task);
|
|
5594
5941
|
$('gdRevisionInput').value = '';
|
|
5595
|
-
closeGoalDetail();
|
|
5942
|
+
if (body.outcome !== 'answer') closeGoalDetail(); // a question leaves the card open for its answer
|
|
5596
5943
|
render();
|
|
5597
5944
|
} catch { showErr('Dismiss failed.'); }
|
|
5598
5945
|
$('gdApprove').disabled = false; $('gdDismiss').disabled = false; $('gdSend').disabled = false;
|
|
@@ -5648,11 +5995,13 @@ $('gdRevisionInput').addEventListener('keydown', (e) => {
|
|
|
5648
5995
|
|
|
5649
5996
|
function render(){
|
|
5650
5997
|
renderProjects(); renderStream(); renderQueue(); renderTasks(); renderActPanel(); wireWfEditButtons();
|
|
5998
|
+
renderCtxBar(); // the folder above the composer follows the active project
|
|
5651
5999
|
renderFlagship();
|
|
5652
6000
|
if (state.reviewOpenGoal != null) renderReviewChecklist();
|
|
5653
6001
|
if (state.goalDetailOpen != null) renderGoalDetail();
|
|
5654
6002
|
if (typeof SA !== 'undefined' && SA.open) saSync(); // keep the open See all Ledger live
|
|
5655
6003
|
const bv = $('botver'); if (bv && state.uiVersion) bv.textContent = 'build ' + String(state.uiVersion).slice(0, 8);
|
|
6004
|
+
paintReplyNote(); // a reply's answer/question survives the re-render it caused
|
|
5656
6005
|
}
|
|
5657
6006
|
|
|
5658
6007
|
// ============================================================================
|
|
@@ -5982,8 +6331,9 @@ function fsBoardBucket(goal, tasks){
|
|
|
5982
6331
|
if (s === 'needsInput') return 'needs'; // a clarifying question is waiting on the user
|
|
5983
6332
|
if (s === 'blocked' && goal?.blocked?.kind === 'paused') return 'doing'; // paused by user — stays in Doing, shown amber "Paused" (Masa 2026-07-19: 1カードのまま黄色に・Needs youへ移動しない)
|
|
5984
6333
|
if (['blocked', 'failed', 'partial', 'interrupted'].includes(s)) return 'needs'; // stopped/errored — needs YOUR action (retry), like the flagship board which puts .drow.err in Needs you. Not "Doing" — Doing is only what's actively moving (Masa 2026-07-18)
|
|
5985
|
-
if (s === 'review') return 'review';
|
|
6334
|
+
if (s === 'review' || s === 'folded') return 'review'; // folded (PR filed for review) reads as Review, not Doing — goalGroupStatus parity (Masa 2026-07-21)
|
|
5986
6335
|
if (s === 'done' || s === 'reverted' || s === 'skipped') return 'done'; // terminal; skipped is finished, not in flight (it used to fall through to the doing default)
|
|
6336
|
+
if (s === 'rejected') return 'rejected'; // Reject = closed. Parked in the list's own Dismissed fold; the board drops it entirely (a closed todo must not linger in Doing — Masa 2026-07-21). Both 'folded' and 'rejected' used to fall through to the doing default.
|
|
5987
6337
|
if (s === 'stacked' || s === 'sending') return 'todo';
|
|
5988
6338
|
if (s === 'running' && !goal?.startedAt) return 'todo'; // goalChip: planned, nothing started yet
|
|
5989
6339
|
// Otherwise the goal is in flight (planning / retesting / running-and-started). BUT a
|
|
@@ -6106,8 +6456,8 @@ function renderFsBoard(){
|
|
|
6106
6456
|
return `<div class="lbc${quiet ? ' is-quiet' : ''}">${h}</div>`;
|
|
6107
6457
|
};
|
|
6108
6458
|
const no = (g) => goalNo.get(g.id) ?? Infinity;
|
|
6109
|
-
const bucket = { todo: [], doing: [], needs: [], review: [], done: [], later: [] };
|
|
6110
|
-
for (const g of pgoals) bucket[fsBoardBucket(g, list)].push(g);
|
|
6459
|
+
const bucket = { todo: [], doing: [], needs: [], review: [], done: [], later: [], rejected: [] };
|
|
6460
|
+
for (const g of pgoals) bucket[fsBoardBucket(g, list)].push(g); // 'rejected' is collected but never rendered as a column below — closed goals disappear from the board
|
|
6111
6461
|
for (const k of Object.keys(bucket)) bucket[k].sort((a, b) => k === 'done' ? no(b) - no(a) : no(a) - no(b));
|
|
6112
6462
|
// To Do / Doing / Needs you / Review / Done / Later — the original design's columns
|
|
6113
6463
|
// (Masa 2026-07-16). "Needs you" = a clarifying question is waiting on the user —
|
|
@@ -6292,6 +6642,10 @@ function fsBucketTodo(){
|
|
|
6292
6642
|
const list = state.tasks.filter((t) => t.projectId === state.active);
|
|
6293
6643
|
const order = state.queueOrder[state.active] ?? [];
|
|
6294
6644
|
const byQueue = (a, b) => { const ia = order.indexOf(a.id), ib = order.indexOf(b.id); return (ia < 0 ? 1e9 : ia) - (ib < 0 ? 1e9 : ib) || a.num - b.num; };
|
|
6645
|
+
// A closed (rejected) goal's tasks stay 'done' after Reject, so they used to leak into the
|
|
6646
|
+
// "✓ Done" fold below AND show in the Dismissed fold — the same closed todo listed twice.
|
|
6647
|
+
// Its home is the Dismissed fold; keep its done tasks out of Done (Masa 2026-07-21).
|
|
6648
|
+
const closedGoalIds = new Set(pgoals.filter((g) => g.status === 'rejected').map((g) => g.id));
|
|
6295
6649
|
return {
|
|
6296
6650
|
pgoals, list,
|
|
6297
6651
|
running: list.filter((t) => t.status === 'running'),
|
|
@@ -6300,7 +6654,7 @@ function fsBucketTodo(){
|
|
|
6300
6654
|
needsInput: pgoals.filter((g) => g.status === 'needsInput'), // a clarifying question is waiting on the user (board's "Needs you")
|
|
6301
6655
|
reviewGoals: pgoals.filter((g) => g.status === 'review'),
|
|
6302
6656
|
queued: list.filter((t) => t.status === 'queued' && !t.reply).sort(byQueue),
|
|
6303
|
-
doneAll: list.filter((t) => ['done', 'skipped'].includes(t.status)).sort((a, b) => b.num - a.num),
|
|
6657
|
+
doneAll: list.filter((t) => ['done', 'skipped'].includes(t.status) && !closedGoalIds.has(t.goalId)).sort((a, b) => b.num - a.num),
|
|
6304
6658
|
later: pgoals.filter((g) => g.status === 'pending'),
|
|
6305
6659
|
rejected: pgoals.filter((g) => g.status === 'rejected'), // 07-20c: Reject parks goals here (own fold, like Done)
|
|
6306
6660
|
};
|
|
@@ -7619,6 +7973,12 @@ async function boot(){
|
|
|
7619
7973
|
if (window.__GALDA_DISCONNECTED) return bootDisconnected(window.__GALDA_DISCONNECTED);
|
|
7620
7974
|
$('input').value = localStorage.getItem('draft') ?? '';
|
|
7621
7975
|
await refresh();
|
|
7976
|
+
// The folder sits above the composer and is read on every screen, so its list is
|
|
7977
|
+
// fetched once at boot rather than on first open: without HOME the bar can only
|
|
7978
|
+
// print the absolute path (measured — it read "/var/folders/…/agent-manager"
|
|
7979
|
+
// instead of "~/agent-manager" until the menu had been opened once). One read of
|
|
7980
|
+
// one directory, not the 500KB-per-task class the on-open rule exists for.
|
|
7981
|
+
loadRepos();
|
|
7622
7982
|
flushOutbox();
|
|
7623
7983
|
connectSSE();
|
|
7624
7984
|
startHealthPoll();
|
|
@@ -7659,7 +8019,7 @@ async function postOutboxItem(item){
|
|
|
7659
8019
|
posting.add(item.oid);
|
|
7660
8020
|
try {
|
|
7661
8021
|
const r = await fetch(withKey('/api/tasks'), { method: 'POST', headers: { 'content-type': 'application/json' },
|
|
7662
|
-
body: JSON.stringify({ projectId: item.projectId, text: item.text, pr: item.pr, model: item.model, effort: item.effort, agent: item.agent, mode: item.mode, skill: item.skill, images: item.images, pending: item.pending, review: item.review }) });
|
|
8022
|
+
body: JSON.stringify({ projectId: item.projectId, text: item.text, pr: item.pr, model: item.model, effort: item.effort, agent: item.agent, mode: item.mode, skill: item.skill, worktree: item.worktree, images: item.images, pending: item.pending, review: item.review }) });
|
|
7663
8023
|
if (r.status === 402) {
|
|
7664
8024
|
const data = await r.json().catch(() => ({}));
|
|
7665
8025
|
outbox.mark(item.oid, 'unsent'); // same status as any other failed send — the existing 15s auto-flush retries it (and starts succeeding the moment a license activates), the existing queue resend affordance also works
|
|
@@ -7831,6 +8191,9 @@ async function send(){
|
|
|
7831
8191
|
oid: `o${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
|
7832
8192
|
projectId: state.active, text, pr: 'auto', model, effort, agent: state.connectedApp,
|
|
7833
8193
|
mode, skill: skill || null,
|
|
8194
|
+
// Only the OFF case travels: the engine stores an explicit false and leaves every
|
|
8195
|
+
// other goal meaning exactly what it meant (engine #268).
|
|
8196
|
+
worktree: worktreeOn() ? undefined : false,
|
|
7834
8197
|
pending: later, review,
|
|
7835
8198
|
images: attach.map((a) => a.path), imageUrls: attach.map((a) => a.url),
|
|
7836
8199
|
status: 'sending', ts: Date.now(),
|
|
@@ -7992,7 +8355,7 @@ function closeSummaryPera(){ $('summaryOverlay').classList.remove('show'); }
|
|
|
7992
8355
|
// 4 archived / 5 reverted — session-local; the server round-trip is what
|
|
7993
8356
|
// settles it (see saSync's "returned to review" reset).
|
|
7994
8357
|
// ============================================================================
|
|
7995
|
-
const SA = { open: false, items: [], diffs: {}, detOpen: new Set(), focusGid: null, focusScrolled: false, focusTimer: null, cur: null };
|
|
8358
|
+
const SA = { open: false, items: [], diffs: {}, actLog: {}, msgs: {}, convOpen: new Set(), shotBox: {}, detOpen: new Set(), focusGid: null, focusScrolled: false, focusTimer: null, cur: null };
|
|
7996
8359
|
window.SA = SA; // top-level const isn't a window property; external checks probe window.SA (same convention as window.state above)
|
|
7997
8360
|
const SA_SVG = {
|
|
7998
8361
|
ok: '<svg viewBox="0 0 24 24"><path d="M4 12l5 5L20 6"/></svg>',
|
|
@@ -8046,6 +8409,30 @@ function saRows(){
|
|
|
8046
8409
|
const t = ts.find((x) => x.goalId === g?.id && !x.reply);
|
|
8047
8410
|
return Array.isArray(t?.todosFinal) ? t.todosFinal : [];
|
|
8048
8411
|
})(),
|
|
8412
|
+
// Screenshots the worker took of what it built (#229). Measured 2026-07-22 on real
|
|
8413
|
+
// runs: it takes 1-3 depending on the ask — three variants as three shots, or four
|
|
8414
|
+
// variants as ONE comparison sheet. A single shot is not a lazy shot, so none of
|
|
8415
|
+
// these get thumbnailed down.
|
|
8416
|
+
//
|
|
8417
|
+
// Folded goals count: a goal folded into this one keeps its own tasks, and those
|
|
8418
|
+
// tasks carry shots. Reading only the primary goal's tasks makes real deliverables
|
|
8419
|
+
// vanish from the screen (measured: #4 folded into #3, its images disappeared).
|
|
8420
|
+
shots: (() => {
|
|
8421
|
+
const ids = [...r.goalIds, ...folded.map((f) => f.id)];
|
|
8422
|
+
return ts.filter((t) => ids.includes(t.goalId)).flatMap((t) => t.shots ?? []);
|
|
8423
|
+
})(),
|
|
8424
|
+
// How many messages this goal's conversation holds (§1.1). A COUNT, not the
|
|
8425
|
+
// conversation: state carries every card on every tick, and 100 cards dragging
|
|
8426
|
+
// 100 threads is what the engine deliberately avoided. 0/undefined means the
|
|
8427
|
+
// section is not rendered at all (§1.5-3: no container for something absent).
|
|
8428
|
+
messageCount: Number(g?.messageCount) || 0,
|
|
8429
|
+
// The task whose implementation log the engine froze at completion (P1.5). The
|
|
8430
|
+
// lines are NOT in state — 1000 of them per task would bloat every page load — so
|
|
8431
|
+
// this is just the id to ask for when the reviewer opens Activity.
|
|
8432
|
+
logTaskId: (() => {
|
|
8433
|
+
const t = ts.find((x) => x.goalId === g?.id && !x.reply && x.activityLog);
|
|
8434
|
+
return t?.id ?? null;
|
|
8435
|
+
})(),
|
|
8049
8436
|
// P1 (Masa 2026-07-21): the AI's own final report, verbatim and untrimmed — the
|
|
8050
8437
|
// card renders it as Markdown and interprets nothing. This is the deliverable for
|
|
8051
8438
|
// every ask that has no running thing to open ("research this", "explain that"),
|
|
@@ -8068,9 +8455,7 @@ function saRows(){
|
|
|
8068
8455
|
testResult: (typeof (g?.reviewSummary?.tests) === 'string')
|
|
8069
8456
|
? { ran: true, ok: true, passed: parseInt(g.reviewSummary.tests, 10) || 0, failed: 0, label: g.reviewSummary.tests }
|
|
8070
8457
|
: r.testResult,
|
|
8071
|
-
answer: g?.reviewSummary?.answer ?? null,
|
|
8072
8458
|
activity: Array.isArray(g?.reviewSummary?.activity) ? g.reviewSummary.activity : null,
|
|
8073
|
-
discussion: Array.isArray(g?.reviewSummary?.discussion) ? g.reviewSummary.discussion : null,
|
|
8074
8459
|
changedDesc: g?.reviewSummary?.changed ?? null,
|
|
8075
8460
|
metaFiles: Array.isArray(g?.reviewSummary?.files) ? g.reviewSummary.files : null,
|
|
8076
8461
|
metaAdd: g?.reviewSummary?.a ?? null, metaDel: g?.reviewSummary?.d ?? null,
|
|
@@ -8085,7 +8470,6 @@ function saRows(){
|
|
|
8085
8470
|
recordingUrl: pt?.proof?.mp4 ? withKey(`/proof/${pt.id}/${pt.proof.mp4}`) : (typeof g?.reviewSummary?.recording === 'string' ? g.reviewSummary.recording : null),
|
|
8086
8471
|
// Local URL (spec §5): a running localhost the reviewer can open ("Local localhost:4809 ↗").
|
|
8087
8472
|
// Falls back to the existing "Local run ↗" (runlocal) button when absent.
|
|
8088
|
-
localUrl: g?.reviewSummary?.localUrl ?? null,
|
|
8089
8473
|
// Client-side diff fallback: the git-backed /diff endpoint is null for seeded/
|
|
8090
8474
|
// offline goals, so "What changed" never renders. A unified-diff string stored on
|
|
8091
8475
|
// the goal (or any of its tasks) lets saGhDiffHtml render locally with no server.
|
|
@@ -8111,35 +8495,38 @@ function saRows(){
|
|
|
8111
8495
|
// (P1, Masa 2026-07-21) saMetaRowHtml() was deleted here — the pre-redesign meta row,
|
|
8112
8496
|
// uncalled since the review-card redesign. Its "Run locally" chip only ever opened a
|
|
8113
8497
|
// note saying no run command was wired; the card's Open it chip now starts the real one.
|
|
8114
|
-
//
|
|
8115
|
-
//
|
|
8116
|
-
//
|
|
8117
|
-
// the AI's own read on the request as "your ask" and it wasn't recognizable).
|
|
8118
|
-
function saContextHtml(it, gk){
|
|
8119
|
-
const goalTxt = it.goalText;
|
|
8120
|
-
const summary = goalTxt
|
|
8121
|
-
? `"${esc(goalTxt.length > 60 ? `${goalTxt.slice(0, 60)}…` : goalTxt)}"`
|
|
8122
|
-
: 'No verbatim ask on record for this goal.';
|
|
8123
|
-
const split = it.siblings
|
|
8124
|
-
? `<div class="lg-splitnote"><div class="hd">Split into ${it.siblings.length} goals — nothing else was dropped</div><ul>${it.siblings.map((s) => `<li class="${s.cur ? 'cur' : ''}">${s.no != null ? `#${esc(s.no)} ` : ''}${esc(s.title)}${s.cur ? ' <b>(this one)</b>' : ''}</li>`).join('')}</ul></div>`
|
|
8125
|
-
: '';
|
|
8126
|
-
const did = it.did?.length ? `<div class="lg-bullethd">What we did</div><ul class="lg-bullets">${it.did.map((s) => `<li>${esc(s)}</li>`).join('')}</ul>` : '';
|
|
8127
|
-
const look = it.look?.length ? `<div class="lg-bullethd">What to look at</div><ul class="lg-bullets">${it.look.map((s) => `<li>${esc(s)}</li>`).join('')}</ul>` : '';
|
|
8128
|
-
const inner = (split || did || look) ? `${split}${did}${look}` : '<div class="lg-notfound">Nothing else on record for this goal.</div>';
|
|
8129
|
-
const open = SA.detOpen.has(`${gk}:ctx`) ? ' open' : '';
|
|
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>`;
|
|
8131
|
-
}
|
|
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.
|
|
8498
|
+
// (saContextHtml() was deleted here — Masa 2026-07-22. The pre-redesign Context fold:
|
|
8499
|
+
// verbatim quote, 'What we did' / 'What to look at' bullets, sibling split note. Nothing
|
|
8500
|
+
// has called it since the review-card redesign; Request and Result carry that ground now.)
|
|
8137
8501
|
// Unified-diff → GitHub two-gutter model (REVIEW-CARD-SPEC §7 "GitHub diff = Bright"):
|
|
8138
8502
|
// 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}.
|
|
8140
|
-
//
|
|
8141
|
-
//
|
|
8142
|
-
//
|
|
8503
|
+
// {o:oldNo|null, n:newNo|null, t:'add'|'del'|'ctx', c:text}. Pure, so it is extracted
|
|
8504
|
+
// and unit-tested in engine/test/review-card-diff.test.mjs.
|
|
8505
|
+
//
|
|
8506
|
+
// Word-level highlight (§7 "変わった単語だけ濃くハイライト", Masa 2026-07-22): the span
|
|
8507
|
+
// is MEASURED between the two lines the reviewer is already looking at — a common
|
|
8508
|
+
// prefix and suffix, with what is left over marked — never guessed. The earlier note
|
|
8509
|
+
// here said the highlight would be "fabricated evidence"; a character comparison of two
|
|
8510
|
+
// shown lines invents nothing. What WOULD be a guess is pairing the wrong removed line
|
|
8511
|
+
// with the wrong added one, so only an unambiguous pair is marked: a run of removals
|
|
8512
|
+
// immediately followed by a run of additions of the SAME length, paired in order. A
|
|
8513
|
+
// 3-for-2 replacement, an add with no matching del, a rewritten line where almost
|
|
8514
|
+
// nothing survives — all keep today's behaviour exactly (tint only, no span).
|
|
8515
|
+
// How many body rows the diff really has, ignoring the display cap. A big change used
|
|
8516
|
+
// to be cut at 80 rows with nothing said (SPEC §7 asks for "…+N 行 · GitHub で開く ↗"
|
|
8517
|
+
// precisely so a reviewer is never shown a slice that looks like the whole thing).
|
|
8518
|
+
// Same skip rules as saUnifiedToGh below — headers and @@ markers are not rows.
|
|
8519
|
+
function saDiffRowCount(diff){
|
|
8520
|
+
let n = 0;
|
|
8521
|
+
for (const raw of String(diff || '').split('\n')) {
|
|
8522
|
+
if (raw === '') continue;
|
|
8523
|
+
if (/^(diff --git|index |--- |\+\+\+ |new file|deleted file|old mode|new mode|similarity|rename |Binary )/.test(raw)) continue;
|
|
8524
|
+
if (/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/.test(raw)) continue;
|
|
8525
|
+
if (raw.startsWith('\\')) continue;
|
|
8526
|
+
n++;
|
|
8527
|
+
}
|
|
8528
|
+
return n;
|
|
8529
|
+
}
|
|
8143
8530
|
function saUnifiedToGh(diff, max){
|
|
8144
8531
|
const cap = max || 80; const out = []; let o = 0, n = 0;
|
|
8145
8532
|
for (const raw of String(diff || '').split('\n')) {
|
|
@@ -8155,10 +8542,66 @@ function saUnifiedToGh(diff, max){
|
|
|
8155
8542
|
}
|
|
8156
8543
|
return out;
|
|
8157
8544
|
}
|
|
8158
|
-
|
|
8159
|
-
|
|
8545
|
+
// The changed span between one removed line and the line that replaced it: skip the
|
|
8546
|
+
// common head, skip the common tail, and what is left in the middle is what changed.
|
|
8547
|
+
// Returns the two offset pairs plus `kept` — how much of the longer line survived —
|
|
8548
|
+
// which is the evidence that these two lines ARE the same line. Null only when the
|
|
8549
|
+
// lines are identical (nothing to point at).
|
|
8550
|
+
function saWordSpan(delText, addText){
|
|
8551
|
+
const a = String(delText ?? ''), b = String(addText ?? '');
|
|
8552
|
+
if (a === b) return null;
|
|
8553
|
+
const max = Math.min(a.length, b.length);
|
|
8554
|
+
let p = 0;
|
|
8555
|
+
while (p < max && a[p] === b[p]) p++;
|
|
8556
|
+
let sfx = 0;
|
|
8557
|
+
while (sfx < max - p && a[a.length - 1 - sfx] === b[b.length - 1 - sfx]) sfx++;
|
|
8558
|
+
return { del: [p, a.length - sfx], add: [p, b.length - sfx], kept: (p + sfx) / Math.max(a.length, b.length, 1) };
|
|
8559
|
+
}
|
|
8560
|
+
// How much of a line must survive before two lines count as the same line. Below this
|
|
8561
|
+
// the "change" is a rewrite: marking almost the whole line says less than the Bright
|
|
8562
|
+
// tint already does, and a low score is also how an unrelated pair gets rejected.
|
|
8563
|
+
const SA_WORD_KEEP = 0.34;
|
|
8564
|
+
// Mark the changed span on removed/added pairs, in place.
|
|
8565
|
+
//
|
|
8566
|
+
// Pairing is the only place a guess could enter, so it is decided by measurement: each
|
|
8567
|
+
// removed line takes the added line it shares the most text with, and the pair is only
|
|
8568
|
+
// marked when that shared text clears SA_WORD_KEEP. Everything else — a pure addition,
|
|
8569
|
+
// a pure removal, a rewrite, an unrelated neighbour — is left exactly as it renders
|
|
8570
|
+
// today (tint only, no span).
|
|
8571
|
+
//
|
|
8572
|
+
// Measured on this product's own history (2026-07-22): the first rule written here
|
|
8573
|
+
// paired blocks in order and required equal lengths, and lit up NOTHING, because the
|
|
8574
|
+
// common shape is "one line replaced, plus a comment added above it" — in-order pairing
|
|
8575
|
+
// matched the replaced line against the comment.
|
|
8576
|
+
function saMarkWordSpans(rows){
|
|
8577
|
+
for (let i = 0; i < rows.length; i++) {
|
|
8578
|
+
if (rows[i].t !== 'del') continue;
|
|
8579
|
+
let d = i; while (d < rows.length && rows[d].t === 'del') d++;
|
|
8580
|
+
let a = d; while (a < rows.length && rows[a].t === 'add') a++;
|
|
8581
|
+
const used = new Set();
|
|
8582
|
+
for (let k = i; k < d; k++) {
|
|
8583
|
+
let best = null, bestIdx = -1;
|
|
8584
|
+
for (let j = d; j < a; j++) {
|
|
8585
|
+
if (used.has(j)) continue;
|
|
8586
|
+
const span = saWordSpan(rows[k].c, rows[j].c);
|
|
8587
|
+
if (span && (!best || span.kept > best.kept)) { best = span; bestIdx = j; }
|
|
8588
|
+
}
|
|
8589
|
+
if (!best || best.kept < SA_WORD_KEEP) continue;
|
|
8590
|
+
used.add(bestIdx);
|
|
8591
|
+
rows[k].hi = best.del;
|
|
8592
|
+
rows[bestIdx].hi = best.add;
|
|
8593
|
+
}
|
|
8594
|
+
i = a - 1;
|
|
8595
|
+
}
|
|
8596
|
+
return rows;
|
|
8597
|
+
}
|
|
8598
|
+
function saGhDiffHtml(diff, pr){
|
|
8599
|
+
const rows = saMarkWordSpans(saUnifiedToGh(diff));
|
|
8160
8600
|
if (!rows.length) return '';
|
|
8161
|
-
|
|
8601
|
+
// Say what was left out. Before this, a 5000-line change rendered its first 80 rows
|
|
8602
|
+
// and stopped — a quiet lie, and the one thing SPEC §7 names as forbidden.
|
|
8603
|
+
const more = Math.max(0, saDiffRowCount(diff) - rows.length);
|
|
8604
|
+
return saGhDiffRowsHtml(rows, more, pr || '');
|
|
8162
8605
|
}
|
|
8163
8606
|
// One Activity transcript row (spec §3): You / tool (mono, bold name) / res (green) /
|
|
8164
8607
|
// Claude. Mirrors the flagship artifact's tx mapping so the pulldown pixel-matches.
|
|
@@ -8168,6 +8611,64 @@ function saTxRowHtml(e){
|
|
|
8168
8611
|
if (e[0] === 'user') return `<div class="txr you"><span class="w">You</span><span class="m">${esc(e[1])}</span></div>`;
|
|
8169
8612
|
return `<div class="txr cl"><span class="w">Claude</span><span class="m">${esc(e[1])}</span></div>`;
|
|
8170
8613
|
}
|
|
8614
|
+
// One line of the frozen implementation log. The worker's own tool calls come through
|
|
8615
|
+
// as "Read app/index.html" / "Bash npm test"; the Manager's own progress notes are
|
|
8616
|
+
// prefixed with ✎ by sendProcessAct() and read as commentary, so they take the quieter
|
|
8617
|
+
// row. Nothing is parsed beyond that split — the line is shown as it was written.
|
|
8618
|
+
function saLogRowHtml(line){
|
|
8619
|
+
const s = String(line ?? '');
|
|
8620
|
+
if (s.startsWith('✎')) return `<div class="txr note"><span class="w"></span><span class="m">${esc(s.replace(/^✎\s*/, ''))}</span></div>`;
|
|
8621
|
+
const m = s.match(/^([A-Z][A-Za-z]*)\s+([\s\S]*)$/);
|
|
8622
|
+
return m
|
|
8623
|
+
? `<div class="txr tool"><span class="w">tool</span><span class="m"><b>${esc(m[1])}</b> ${esc(m[2])}</span></div>`
|
|
8624
|
+
: `<div class="txr cl"><span class="w"></span><span class="m">${esc(s)}</span></div>`;
|
|
8625
|
+
}
|
|
8626
|
+
// One line of the goal's conversation (ONE-CONVERSATION §1.1). Several entrances —
|
|
8627
|
+
// the composer, a review reply, a thread reply — are one column here, because to the
|
|
8628
|
+
// person who wrote them they were one conversation. `via` says which door a line came
|
|
8629
|
+
// through and nothing else: no per-entrance box, no grouping, no summarising. The text
|
|
8630
|
+
// is whatever was actually said (§ "会話を要約・分類・整形しない").
|
|
8631
|
+
const MSG_WHO = { you: 'You', ai: 'AI', manager: 'Manager' };
|
|
8632
|
+
// `thread` is our word for a reply that came in on the goal's own thread; a person reads
|
|
8633
|
+
// it as 'note'. The label is only shown when the entrance CHANGED from the line above —
|
|
8634
|
+
// five rows do not need to say 'chat' three times.
|
|
8635
|
+
const MSG_VIA = { composer: 'chat', review: 'review', thread: 'note' };
|
|
8636
|
+
function saMsgRowHtml(m, prevVia){
|
|
8637
|
+
const who = MSG_WHO[m?.from] ?? 'AI';
|
|
8638
|
+
const cls = m?.from === 'you' ? 'you' : m?.from === 'manager' ? 'mgr' : 'cl';
|
|
8639
|
+
// Manager rows carry no entrance: `who` already says this line is automatic.
|
|
8640
|
+
const via = m?.from === 'manager' ? '' : (MSG_VIA[m?.via] ?? '');
|
|
8641
|
+
const show = via && via !== prevVia;
|
|
8642
|
+
return `<div class="txr ${cls}"><span class="w">${who}</span><span class="m">${esc(String(m?.text ?? ''))}</span>`
|
|
8643
|
+
+ (show ? `<span class="cvia">${via}</span>` : '') + '</div>';
|
|
8644
|
+
}
|
|
8645
|
+
// The rows of a conversation, with each entrance shown only where it changes. A Manager
|
|
8646
|
+
// line never updates `prevVia` — a note in the middle must not make the chat that
|
|
8647
|
+
// continues after it look like a new entrance.
|
|
8648
|
+
//
|
|
8649
|
+
// Long ones fold in the MIDDLE (§B3): the first ask and the latest exchange are what get
|
|
8650
|
+
// read, the middle is what you go back for. From the 9th message: first 2 · +N more ·
|
|
8651
|
+
// last 4. Nothing is summarised and nothing is dropped — pressing it shows everything.
|
|
8652
|
+
// The row right after a fold always prints its entrance, because the change may be
|
|
8653
|
+
// inside what was folded away.
|
|
8654
|
+
const CONV_FOLD_FROM = 9, CONV_HEAD = 2, CONV_TAIL = 4;
|
|
8655
|
+
function saMsgRowsHtml(msgs, expanded){
|
|
8656
|
+
const list = msgs ?? [];
|
|
8657
|
+
const fold = !expanded && list.length >= CONV_FOLD_FROM;
|
|
8658
|
+
const shown = fold ? [...list.slice(0, CONV_HEAD), null, ...list.slice(-CONV_TAIL)] : list;
|
|
8659
|
+
const hidden = list.length - CONV_HEAD - CONV_TAIL;
|
|
8660
|
+
let prevVia = null;
|
|
8661
|
+
return shown.map((m) => {
|
|
8662
|
+
if (m === null) {
|
|
8663
|
+
prevVia = null;
|
|
8664
|
+
return `<div class="txr res convmore"><span class="w"></span><span class="m">+${hidden} more</span></div>`;
|
|
8665
|
+
}
|
|
8666
|
+
const html = saMsgRowHtml(m, prevVia);
|
|
8667
|
+
if (m?.from !== 'manager') prevVia = MSG_VIA[m?.via] ?? prevVia;
|
|
8668
|
+
return html;
|
|
8669
|
+
}).join('');
|
|
8670
|
+
}
|
|
8671
|
+
|
|
8171
8672
|
// Structured GitHub diff (REVIEW-CARD-SPEC §7): renders {o,n,t,c,h} rows with a
|
|
8172
8673
|
// word-level highlight (h) and, for a huge change, a "…+N 行 · GitHub で開く ↗"
|
|
8173
8674
|
// footer (only linking to GitHub when a PR exists). Mirrors the flagship artifact's
|
|
@@ -8178,7 +8679,12 @@ function saGhDiffRowsHtml(rows, more, pr){
|
|
|
8178
8679
|
const cls = l.t === 'add' ? 'gh-add' : l.t === 'del' ? 'gh-del' : 'gh-ctx';
|
|
8179
8680
|
const mk = l.t === 'add' ? '+' : l.t === 'del' ? '−' : '';
|
|
8180
8681
|
let code = esc(l.c);
|
|
8181
|
-
if (
|
|
8682
|
+
if (Array.isArray(l.hi)) {
|
|
8683
|
+
// Offsets, not a substring search: a word that appears twice on the line must be
|
|
8684
|
+
// marked where it actually changed, not at its first occurrence.
|
|
8685
|
+
const [a, b] = l.hi;
|
|
8686
|
+
code = `${esc(l.c.slice(0, a))}<span class="gh-hi">${esc(l.c.slice(a, b))}</span>${esc(l.c.slice(b))}`;
|
|
8687
|
+
} else if (l.h) { const h = esc(l.h); code = code.replace(h, `<span class="gh-hi">${h}</span>`); }
|
|
8182
8688
|
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
8689
|
}).join('') + '</div>';
|
|
8184
8690
|
const foot = more ? `<div class="ghfoot"><span>…+${more} 行</span>${pr ? ` · <a class="ghopen" href="${esc(pr)}" target="_blank" rel="noopener">GitHub で開く ↗</a>` : ''}</div>` : '';
|
|
@@ -8207,7 +8713,15 @@ function saWorkflowHtml(it){
|
|
|
8207
8713
|
// P1 (Masa 2026-07-21): "Proof captured □" is gone. We no longer go take photos of
|
|
8208
8714
|
// the work, so an empty box there was scoring the goal against a step that isn't
|
|
8209
8715
|
// part of the process any more — a permanent unchecked box nobody could ever check.
|
|
8210
|
-
|
|
8716
|
+
// "Deploy" is gone too (Masa 2026-07-22): nothing in the product observes a deploy,
|
|
8717
|
+
// so that box could never be ticked — the same permanent-□ problem as Proof captured.
|
|
8718
|
+
// Merge stays because it IS observed: the PR poller flips the goal to done on merge.
|
|
8719
|
+
//
|
|
8720
|
+
// (PRD §5.5-A/15 asks for the USER'S OWN pipeline — PRD→design→implement→check→PR→
|
|
8721
|
+
// merge→deploy, varying per person. There is no place to record such a pipeline yet,
|
|
8722
|
+
// so inventing one here would be this layer guessing someone's process. Left as a
|
|
8723
|
+
// decision, not faked.)
|
|
8724
|
+
const steps = [['Implemented', 'ok'], ['Tested', ran ? 'ok' : 'todo'], ['In review', 'now'], ['Merge', 'todo']];
|
|
8211
8725
|
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>';
|
|
8212
8726
|
}
|
|
8213
8727
|
function saItemHtml(it, i){
|
|
@@ -8250,9 +8764,9 @@ function saItemHtml(it, i){
|
|
|
8250
8764
|
const kind = saEvidenceKind(filePaths, hasProofImg, gtr);
|
|
8251
8765
|
const gk = it.goalId;
|
|
8252
8766
|
const rs = it.goal?.reviewSummary || {};
|
|
8253
|
-
//
|
|
8254
|
-
//
|
|
8255
|
-
const isConv =
|
|
8767
|
+
// A conversational task (no code change) has no Proof / What changed to show. Its
|
|
8768
|
+
// Result IS the answer (P1) — the old separate Answer section is gone.
|
|
8769
|
+
const isConv = !it.report && !filePaths.length;
|
|
8256
8770
|
// ── meta (spec §5) — fully data-driven / hide-empty: each chip renders only when its
|
|
8257
8771
|
// data exists (no PR → no PR chip · no tests → no Tests · no file totals → no Files ·
|
|
8258
8772
|
// no recording → no Verify). PR = bold green, no underline (a real <a href>). ──
|
|
@@ -8269,10 +8783,9 @@ function saItemHtml(it, i){
|
|
|
8269
8783
|
if (testsLabel) chips.push(`<span class="mx"><span class="mk">Tests</span> <b class="ok">${esc(testsLabel)}</b></span>`);
|
|
8270
8784
|
// Files: hide-empty — only with changed files AND totals (a bare-count chip reads odd).
|
|
8271
8785
|
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
|
|
8273
|
-
//
|
|
8274
|
-
|
|
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>`); }
|
|
8786
|
+
// (The Local chip was deleted here — Masa 2026-07-22. It read reviewSummary.localUrl,
|
|
8787
|
+
// a key parseReviewSummary never emits, so it only ever appeared in demo data; and the
|
|
8788
|
+
// real thing it promised is the Open it section.)
|
|
8276
8789
|
// (Open it is NOT a meta chip — Masa 2026-07-22. It was saying the same thing as the
|
|
8277
8790
|
// Open it section below, in the PR chip's vocabulary, so the card spoke two languages
|
|
8278
8791
|
// about one thing. It lives in the body now, in the same typography as every other
|
|
@@ -8298,7 +8811,9 @@ function saItemHtml(it, i){
|
|
|
8298
8811
|
// address the worker declared IS the control (press it → the process starts on demand →
|
|
8299
8812
|
// it opens); its own note sits under it, and that note is where the status and any
|
|
8300
8813
|
// failure reason land after the press. Nothing declared → no section (§1.5-3). ──
|
|
8301
|
-
|
|
8814
|
+
// it.run?.compare/title alone (no cmd/url) has nothing a press can start — a
|
|
8815
|
+
// compare-only or title-only declaration must not draw the "Start it" button.
|
|
8816
|
+
const openBody = (it.run?.cmd || it.run?.url)
|
|
8302
8817
|
? `<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
8818
|
: '';
|
|
8304
8819
|
// ── Also carries (Masa 2026-07-21): goals folded onto this review are logged HERE, inside
|
|
@@ -8333,6 +8848,21 @@ function saItemHtml(it, i){
|
|
|
8333
8848
|
// change explains itself here, and an image the AI pasted shows up here as an image.
|
|
8334
8849
|
// We interpret nothing; mdLite only turns Markdown into the shapes it already means. ──
|
|
8335
8850
|
const reportBody = it.report ? `<div class="rreport">${mdLite(it.report, { images: false })}</div>` : '';
|
|
8851
|
+
// ── Screenshots (P2, Masa 2026-07-22): the pictures the AI took of its own result.
|
|
8852
|
+
// Full width, at their own aspect — a four-variant comparison sheet is unreadable
|
|
8853
|
+
// shrunk into a thumbnail, and that is the shape the AI actually produces. The file
|
|
8854
|
+
// name it chose is the caption (it names them variant-1-linear.png / compare.png), so
|
|
8855
|
+
// it labels its own work; we add nothing. ──
|
|
8856
|
+
// A before/after pair is two shots the worker named as such — it decides whether the
|
|
8857
|
+
// change had a "before" worth shooting, we only recognise the naming. Anything else is
|
|
8858
|
+
// shown as the plain list.
|
|
8859
|
+
const shotPair = saShotPair(it.shots, it.run?.compare);
|
|
8860
|
+
const boxed = shotPair ? SA.shotBox[it.goalId] : null;
|
|
8861
|
+
const shotsBody = shotPair
|
|
8862
|
+
? `<div class="rshotpair" data-shotpair="${it.goalId}">${typeof boxed === 'string' ? boxed : saPlainPairHtml(shotPair)}</div>`
|
|
8863
|
+
: it.shots?.length
|
|
8864
|
+
? it.shots.map((sh) => `<figure class="rshot"><img src="${esc(sh.url)}" alt="${esc(sh.name || 'screenshot')}" loading="lazy"><figcaption>${esc(String(sh.name || '').replace(/\.[a-z]+$/i, ''))}</figcaption></figure>`).join('')
|
|
8865
|
+
: '';
|
|
8336
8866
|
// ── Proof (spec §6/§11): before/after (identical size) when a real image exists; a
|
|
8337
8867
|
// run-log for logic-only tests; dropped entirely for a conversational task. ──
|
|
8338
8868
|
let proofInner = '';
|
|
@@ -8355,7 +8885,7 @@ function saItemHtml(it, i){
|
|
|
8355
8885
|
: (clientDiff && filePaths.length) ? `<div class="rfiles">${filePaths.map((p) => esc(p)).join('<br>')}</div>` : '';
|
|
8356
8886
|
const diffHtml = it.ghRows
|
|
8357
8887
|
? saGhDiffRowsHtml(it.ghRows, it.diffTotal || 0, it.pr || '')
|
|
8358
|
-
: hasGitDiff ? saGhDiffHtml(d.diff) : clientDiff ? saGhDiffHtml(clientDiff) : '';
|
|
8888
|
+
: hasGitDiff ? saGhDiffHtml(d.diff, it.pr) : clientDiff ? saGhDiffHtml(clientDiff, it.pr) : '';
|
|
8359
8889
|
const changedBody = isConv ? '' : (changedDescHtml + filesListHtml + diffHtml);
|
|
8360
8890
|
// ── sensitive-area note (real: classifyChangeRisk forced the pause) ──
|
|
8361
8891
|
const risk = it.risk;
|
|
@@ -8373,16 +8903,49 @@ function saItemHtml(it, i){
|
|
|
8373
8903
|
const txArr = (it.activity && it.activity.length)
|
|
8374
8904
|
? it.activity.map((e) => saTxRowHtml(e))
|
|
8375
8905
|
: ((!reportBody && it.did?.length) ? it.did.map((s) => saTxRowHtml(['claude', s])) : []);
|
|
8376
|
-
|
|
8377
|
-
|
|
8378
|
-
|
|
8379
|
-
|
|
8906
|
+
// (Discussion was deleted here — Masa 2026-07-22. It read reviewSummary.discussion,
|
|
8907
|
+
// which parseReviewSummary never emits, so it never rendered on a real goal; replies
|
|
8908
|
+
// already live in the card's own reply box.)
|
|
8909
|
+
const threadHtml = '';
|
|
8910
|
+
// ── Activity = the implementation log (P1.5, Masa 2026-07-22): what the AI actually
|
|
8911
|
+
// did, read AFTER it finished. Fetched only when the section is opened — a task's log
|
|
8912
|
+
// runs to ~500KB and most cards are never expanded — so the closed state is one line
|
|
8913
|
+
// and the lines arrive on the first open (same "only when asked" shape as Open it).
|
|
8914
|
+
// Once fetched they live in SA.actLog so an SSE rebuild doesn't wipe them.
|
|
8915
|
+
const logLines = it.logTaskId != null ? SA.actLog[it.logTaskId] : null;
|
|
8916
|
+
const logHtml = Array.isArray(logLines)
|
|
8917
|
+
? (logLines.length
|
|
8918
|
+
? logLines.map((l) => saLogRowHtml(l)).join('')
|
|
8919
|
+
: '<div class="txr res"><span class="w"></span><span class="m">Nothing was logged for this one.</span></div>')
|
|
8920
|
+
: '<div class="txr res"><span class="w"></span><span class="m">Loading…</span></div>';
|
|
8380
8921
|
const actOpen = SA.detOpen.has(`${gk}:act`) ? ' open' : '';
|
|
8381
8922
|
const txFirst = txArr[0] || '<div class="txr"><span class="w"></span><span class="m">Activity</span></div>';
|
|
8382
8923
|
const txRest = txArr.slice(1).join('');
|
|
8383
8924
|
const discInner = threadHtml ? `<div class="rsub">Discussion</div><div class="rthread">${threadHtml}</div>` : '';
|
|
8384
8925
|
const activitySec = (txArr.length || threadHtml)
|
|
8385
8926
|
? `<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>`
|
|
8927
|
+
: it.logTaskId != null
|
|
8928
|
+
? `<section class="rsec ract"><div class="rsec-h">Activity</div><details class="lacc" data-detkey="${gk}:act" data-actlog="${it.logTaskId}"${actOpen}><summary><i class="ltog">›</i><div class="rtx lfirst">${saTxRowHtml(['user', it.goalText || it.title])}</div></summary><div class="lbody"><div class="rtx" data-actbody="${it.logTaskId}">${logHtml}</div>${discInner}</div></details></section>`
|
|
8929
|
+
: '';
|
|
8930
|
+
// ── Conversation (ONE-CONVERSATION §1.1) ─────────────────────────────────────
|
|
8931
|
+
// Everything said about this ticket, in the order it was said, whichever door it
|
|
8932
|
+
// came through. Rendered ABOVE Activity: Activity is what the AI did, and what was
|
|
8933
|
+
// SAID comes before what was done. Fetched on open only — state carries a count,
|
|
8934
|
+
// never the thread (100 cards would drag 100 threads on every tick). A goal nobody
|
|
8935
|
+
// has talked to has no section at all (§1.5-3 存在しないものの器を先に見せない).
|
|
8936
|
+
const msgs = SA.msgs[it.goalId];
|
|
8937
|
+
const msgOpen = SA.detOpen.has(`${gk}:msg`) ? ' open' : '';
|
|
8938
|
+
const msgLast = Array.isArray(msgs) && msgs.length ? msgs[msgs.length - 1] : null;
|
|
8939
|
+
const msgSummary = msgLast
|
|
8940
|
+
? saMsgRowHtml(msgLast, null)
|
|
8941
|
+
: `<div class="txr"><span class="w"></span><span class="m">${it.messageCount} message${it.messageCount === 1 ? '' : 's'}</span></div>`;
|
|
8942
|
+
const msgBody = Array.isArray(msgs)
|
|
8943
|
+
? (msgs.length
|
|
8944
|
+
? saMsgRowsHtml(msgs, SA.convOpen.has(it.goalId))
|
|
8945
|
+
: '<div class="txr res"><span class="w"></span><span class="m">Nothing was said on this one.</span></div>')
|
|
8946
|
+
: '<div class="txr res"><span class="w"></span><span class="m">Loading…</span></div>';
|
|
8947
|
+
const conversationSec = it.messageCount > 0
|
|
8948
|
+
? `<section class="rsec rconv"><div class="rsec-h">Conversation</div><details class="lacc" data-detkey="${gk}:msg" data-msgs="${it.goalId}"${msgOpen}><summary><i class="ltog">›</i><div class="rtx lfirst">${msgSummary}</div></summary><div class="lbody"><div class="rtx" data-msgbody="${it.goalId}">${msgBody}</div></div></details></section>`
|
|
8386
8949
|
: '';
|
|
8387
8950
|
const S = (label, body) => body ? `<section class="rsec"><div class="rsec-h">${label}</div>${body}</section>` : '';
|
|
8388
8951
|
return `<div class="sa-item lg redesign${it.goalId === SA.cur ? ' cur' : ''}" data-gid="${it.goalId}">
|
|
@@ -8394,15 +8957,15 @@ function saItemHtml(it, i){
|
|
|
8394
8957
|
${S('Also carries', carriesBody)}
|
|
8395
8958
|
${S('What to check', checkBody)}
|
|
8396
8959
|
${S('Result', reportBody)}
|
|
8960
|
+
${S('Screenshots', shotsBody)}
|
|
8397
8961
|
${S('Steps', stepsBody)}
|
|
8398
|
-
${S('Answer', it.answer ? `<p class="rbody">${esc(it.answer)}</p>` : '')}
|
|
8399
8962
|
${S('Proof', proofInner)}
|
|
8400
8963
|
${S('What changed', changedBody)}
|
|
8401
|
-
${S('Not done', it.risk?.summary ? `<p class="rbody rrisk">${esc(it.risk.summary)}</p>` : '')}
|
|
8402
8964
|
${riskHtml}
|
|
8965
|
+
${conversationSec}
|
|
8403
8966
|
${activitySec}
|
|
8404
8967
|
${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>
|
|
8968
|
+
<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}" data-goal="${it.goalId}" 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>
|
|
8406
8969
|
</div>`;
|
|
8407
8970
|
}
|
|
8408
8971
|
// Scroll the See-all body so a card's top edge (.rhead) sits at the top of the visible
|
|
@@ -8413,6 +8976,59 @@ function saScrollCardToTop(el){
|
|
|
8413
8976
|
if (!body || !el) return;
|
|
8414
8977
|
body.scrollTop += el.getBoundingClientRect().top - body.getBoundingClientRect().top;
|
|
8415
8978
|
}
|
|
8979
|
+
// Fetch one task's frozen implementation log and paint it into the open card. Cached in
|
|
8980
|
+
// SA.actLog (including an empty result) so a card can be opened and closed without
|
|
8981
|
+
// asking twice. A failure writes an honest line rather than an empty box — the log
|
|
8982
|
+
// either exists or it doesn't, and we say which.
|
|
8983
|
+
async function saLoadActivityLog(taskId){
|
|
8984
|
+
if (!Number.isFinite(taskId) || SA.actLog[taskId] !== undefined) return;
|
|
8985
|
+
SA.actLog[taskId] = null; // in flight — keeps a second toggle from re-fetching
|
|
8986
|
+
let lines = [];
|
|
8987
|
+
try {
|
|
8988
|
+
const r = await fetch(withKey(`/api/tasks/${taskId}/activity`));
|
|
8989
|
+
lines = r.ok ? ((await r.json()).lines ?? []) : [];
|
|
8990
|
+
} catch { lines = []; }
|
|
8991
|
+
SA.actLog[taskId] = lines;
|
|
8992
|
+
const body = document.querySelector(`#seeall [data-actbody="${taskId}"]`);
|
|
8993
|
+
if (!body) return;
|
|
8994
|
+
body.innerHTML = lines.length
|
|
8995
|
+
? lines.map((l) => saLogRowHtml(l)).join('')
|
|
8996
|
+
: '<div class="txr res"><span class="w"></span><span class="m">Nothing was logged for this one.</span></div>';
|
|
8997
|
+
}
|
|
8998
|
+
// Fetch one goal's conversation and paint it into the open card. Cached in SA.msgs
|
|
8999
|
+
// (an empty result included) so opening and closing a card doesn't ask twice. A failed
|
|
9000
|
+
// request writes an honest line instead of an empty box — the same rule the
|
|
9001
|
+
// implementation log follows.
|
|
9002
|
+
async function saLoadMessages(goalId){
|
|
9003
|
+
if (!Number.isFinite(goalId) || SA.msgs[goalId] !== undefined) return;
|
|
9004
|
+
SA.msgs[goalId] = null; // in flight — a second toggle must not re-fetch
|
|
9005
|
+
let msgs = [];
|
|
9006
|
+
try {
|
|
9007
|
+
const r = await fetch(withKey(`/api/goals/${goalId}/messages`));
|
|
9008
|
+
msgs = r.ok ? ((await r.json()).messages ?? []) : [];
|
|
9009
|
+
} catch { msgs = []; }
|
|
9010
|
+
SA.msgs[goalId] = msgs;
|
|
9011
|
+
const body = document.querySelector(`#seeall [data-msgbody="${goalId}"]`);
|
|
9012
|
+
if (!body) return;
|
|
9013
|
+
body.innerHTML = msgs.length
|
|
9014
|
+
? saMsgRowsHtml(msgs, SA.convOpen.has(goalId))
|
|
9015
|
+
: '<div class="txr res"><span class="w"></span><span class="m">Nothing was said on this one.</span></div>';
|
|
9016
|
+
}
|
|
9017
|
+
// After first paint, compute the red box for any card showing a before/after pair and
|
|
9018
|
+
// patch that card's node directly — a full re-render here would close every open
|
|
9019
|
+
// pulldown just to draw a box. Cached per goal (null included) so it runs once.
|
|
9020
|
+
async function saEnhanceShotBoxes(){
|
|
9021
|
+
const targets = SA.items.filter((it) => saShotPair(it.shots, it.run?.compare) && SA.shotBox[it.goalId] === undefined);
|
|
9022
|
+
await Promise.all(targets.map(async (it) => {
|
|
9023
|
+
const pair = saShotPair(it.shots, it.run?.compare);
|
|
9024
|
+
let html = null;
|
|
9025
|
+
try { html = await shotsBoxedHtml(pair.before, pair.after); } catch { html = null; }
|
|
9026
|
+
SA.shotBox[it.goalId] = html;
|
|
9027
|
+
if (!html || !SA.open) return;
|
|
9028
|
+
const host = document.querySelector(`#seeall [data-shotpair="${it.goalId}"]`);
|
|
9029
|
+
if (host) host.innerHTML = html;
|
|
9030
|
+
}));
|
|
9031
|
+
}
|
|
8416
9032
|
function saRender(){
|
|
8417
9033
|
const S = $('seeall'); if (!S) return;
|
|
8418
9034
|
const pending = SA.items.filter((x) => x.st === 0);
|
|
@@ -8425,11 +9041,21 @@ function saRender(){
|
|
|
8425
9041
|
// Native <details> would collapse on every rebuild (SSE tick, approve, etc.) —
|
|
8426
9042
|
// track what's open per goal+section in SA.detOpen (persists across re-renders)
|
|
8427
9043
|
// and wire each one's toggle to keep that set current.
|
|
9044
|
+
paintReplyNote(); // the answer/question under a card's reply box survives its rebuild
|
|
8428
9045
|
for (const det of S.querySelectorAll('[data-detkey]')) {
|
|
8429
9046
|
det.addEventListener('toggle', () => {
|
|
8430
9047
|
const k = det.dataset.detkey;
|
|
8431
9048
|
if (det.open) SA.detOpen.add(k); else SA.detOpen.delete(k);
|
|
9049
|
+
// Opening Activity is what fetches the implementation log (Masa 2026-07-22:
|
|
9050
|
+
// "開いた時だけ取りに行く"). Closed cards cost nothing; a reopened one is served
|
|
9051
|
+
// from SA.actLog.
|
|
9052
|
+
if (det.open && det.dataset.actlog) saLoadActivityLog(Number(det.dataset.actlog));
|
|
9053
|
+
if (det.open && det.dataset.msgs) saLoadMessages(Number(det.dataset.msgs));
|
|
8432
9054
|
});
|
|
9055
|
+
// A card re-rendered while its Activity was already open (SSE tick) keeps the log
|
|
9056
|
+
// it had — the fetch below no-ops once SA.actLog holds the answer.
|
|
9057
|
+
if (det.open && det.dataset.actlog) saLoadActivityLog(Number(det.dataset.actlog));
|
|
9058
|
+
if (det.open && det.dataset.msgs) saLoadMessages(Number(det.dataset.msgs));
|
|
8433
9059
|
}
|
|
8434
9060
|
// "ないものは載せない" (Masa): a Proof <img> whose file is missing/broken must never
|
|
8435
9061
|
// show a broken-image placeholder. proofImg is only a stored filename, so a file that
|
|
@@ -8511,6 +9137,7 @@ function saOpen(focusGoalId){
|
|
|
8511
9137
|
if (el) saScrollCardToTop(el);
|
|
8512
9138
|
});
|
|
8513
9139
|
saFetchDiffs();
|
|
9140
|
+
saEnhanceShotBoxes();
|
|
8514
9141
|
}
|
|
8515
9142
|
function saClose(){ SA.open = false; SA.focusGid = null; if (SA.focusTimer) { clearTimeout(SA.focusTimer); SA.focusTimer = null; } $('seeall').classList.remove('open'); }
|
|
8516
9143
|
// Continuous review (Masa 2026-07-08): move a persistent "current" card with
|
|
@@ -8547,6 +9174,49 @@ async function saCall(ids, path, body){
|
|
|
8547
9174
|
}
|
|
8548
9175
|
return r;
|
|
8549
9176
|
}
|
|
9177
|
+
// The review card's reply box (§1.3/§1.4). It says "Reply, ask, or send back" —
|
|
9178
|
+
// until now all three did the same thing: sent the goal back to be redone.
|
|
9179
|
+
//
|
|
9180
|
+
// Unlike saAct this does NOT show its outcome first. "Feedback sent — returned to
|
|
9181
|
+
// To Do" is a lie when the reply was a question, and an optimistic lie is worse
|
|
9182
|
+
// than a moment of waiting: the card would say the work was sent back while the
|
|
9183
|
+
// goal sat untouched in review.
|
|
9184
|
+
async function saReplyCall(it, text, outcome){
|
|
9185
|
+
const r = await saCall(it.ids, 'dismiss', { text, outcome });
|
|
9186
|
+
if (!r?.ok) throw new Error('reply failed');
|
|
9187
|
+
return r.json().catch(() => null);
|
|
9188
|
+
}
|
|
9189
|
+
async function saSendReply(it, i, text){
|
|
9190
|
+
const goalId = it.goalId;
|
|
9191
|
+
// By goal, not by row index: the list re-sorts while the AI is thinking, and an
|
|
9192
|
+
// index-based anchor then points at someone else's card (or nothing).
|
|
9193
|
+
const sel = `.sa-chat[data-goal="${goalId}"]`;
|
|
9194
|
+
const seen = state.goals.find((g) => g.id === goalId)?.messageCount ?? 0;
|
|
9195
|
+
it.inflight = true; saRender();
|
|
9196
|
+
showReplySent(sel, text, goalId); // BEFORE the round trip — this is the ~7s that felt broken
|
|
9197
|
+
try {
|
|
9198
|
+
let body = await saReplyCall(it, text, 'auto');
|
|
9199
|
+
if (body?.outcome === 'unsure') {
|
|
9200
|
+
it.inflight = false; saRender();
|
|
9201
|
+
const picked = await askReplyDestination(sel, body.choices);
|
|
9202
|
+
if (!picked) return; // nothing was touched; the words stay in the box
|
|
9203
|
+
it.inflight = true; saRender();
|
|
9204
|
+
body = await saReplyCall(it, text, picked);
|
|
9205
|
+
}
|
|
9206
|
+
it.inflight = false;
|
|
9207
|
+
if (body?.outcome === 'answer') {
|
|
9208
|
+
// The goal never moved, so the card stays exactly where it is — and the
|
|
9209
|
+
// answer appears under the box that asked for it.
|
|
9210
|
+
saRender();
|
|
9211
|
+
showAnswerWhenItArrives(sel, goalId, seen + 1);
|
|
9212
|
+
return;
|
|
9213
|
+
}
|
|
9214
|
+
it.st = 3; saSync(); saAdvanceFocus(goalId);
|
|
9215
|
+
} catch {
|
|
9216
|
+
it.inflight = false; saRender();
|
|
9217
|
+
showErr('Action failed.');
|
|
9218
|
+
}
|
|
9219
|
+
}
|
|
8550
9220
|
// one action round-trip: optimistic one-liner → endpoint → settle or roll back
|
|
8551
9221
|
async function saAct(it, st, path, body){
|
|
8552
9222
|
it.st = st; it.inflight = true; saRender();
|
|
@@ -8563,6 +9233,18 @@ async function saAct(it, st, path, body){
|
|
|
8563
9233
|
const SA_UNDO = { 1: 'revert-review', 2: 'reopen', 3: 'undismiss', 4: 'unarchive' }; // 07-20c: st 2 = Reject, undo = reopen
|
|
8564
9234
|
$('seeall').addEventListener('mousedown', (e) => { if (e.target === $('seeall')) saClose(); }); // no-dim dialog: outside = close
|
|
8565
9235
|
$('seeall').addEventListener('click', async (e) => {
|
|
9236
|
+
// "+N more" in a conversation: show the whole thing, in place. Nothing was summarised
|
|
9237
|
+
// away, so this only has to stop hiding it (§B3).
|
|
9238
|
+
const more = e.target.closest('.convmore');
|
|
9239
|
+
if (more) {
|
|
9240
|
+
const gid = Number(more.closest('[data-msgbody]')?.dataset.msgbody);
|
|
9241
|
+
if (Number.isFinite(gid)) {
|
|
9242
|
+
SA.convOpen.add(gid);
|
|
9243
|
+
const body = document.querySelector(`#seeall [data-msgbody="${gid}"]`);
|
|
9244
|
+
if (body) body.innerHTML = saMsgRowsHtml(SA.msgs[gid], true);
|
|
9245
|
+
}
|
|
9246
|
+
return;
|
|
9247
|
+
}
|
|
8566
9248
|
const b = e.target.closest('[data-saact]');
|
|
8567
9249
|
if (!b) return;
|
|
8568
9250
|
const [k, v] = b.dataset.saact.split(':'); const i = Number(v);
|
|
@@ -8613,7 +9295,7 @@ $('seeall').addEventListener('click', async (e) => {
|
|
|
8613
9295
|
const inp = $('seeall').querySelector(`.sa-chat[data-i="${i}"]`);
|
|
8614
9296
|
const text = inp?.value.trim();
|
|
8615
9297
|
if (!text) { inp?.focus(); return; }
|
|
8616
|
-
return
|
|
9298
|
+
return saSendReply(it, i, text);
|
|
8617
9299
|
}
|
|
8618
9300
|
if (k === 'undo') {
|
|
8619
9301
|
const path = SA_UNDO[it.st];
|
|
@@ -8631,7 +9313,7 @@ $('seeall').addEventListener('keydown', (e) => {
|
|
|
8631
9313
|
if (!text) return;
|
|
8632
9314
|
e.preventDefault();
|
|
8633
9315
|
const it = SA.items[Number(e.target.dataset.i)];
|
|
8634
|
-
if (it)
|
|
9316
|
+
if (it) saSendReply(it, Number(e.target.dataset.i), text); // no extra confirm (PRD §5.1): Enter = sent
|
|
8635
9317
|
});
|
|
8636
9318
|
document.addEventListener('keydown', (e) => {
|
|
8637
9319
|
if (!SA.open || ['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
|
|
@@ -8708,6 +9390,115 @@ setAgentEffort(state.connectedApp);
|
|
|
8708
9390
|
renderAppSelect();
|
|
8709
9391
|
renderModelSelect();
|
|
8710
9392
|
renderEffortSelect();
|
|
9393
|
+
// ---- Context Bar: the folder this project works in --------------------------
|
|
9394
|
+
// A browser cannot open an OS folder dialog that returns a real path, so the choice is
|
|
9395
|
+
// the repos the engine detected (GET /api/repos) plus a typed path — the same two-part
|
|
9396
|
+
// shape the "which folder?" question card has always used. Rendered with the composer's
|
|
9397
|
+
// own dropdown vocabulary (.appmenu/.appopt); no new component.
|
|
9398
|
+
// Mirrors engine/lib.mjs folderName() — the browser cannot import it, and the engine
|
|
9399
|
+
// test pins them to the same answers so the two can't drift apart.
|
|
9400
|
+
function folderName(dir){
|
|
9401
|
+
const d = String(dir ?? '').replace(/\/+$/, '');
|
|
9402
|
+
return d ? d.split('/').pop() : '';
|
|
9403
|
+
}
|
|
9404
|
+
function folderShort(dir){
|
|
9405
|
+
const home = String(state.repoHome || '');
|
|
9406
|
+
const d = String(dir || '');
|
|
9407
|
+
return home && d.startsWith(home) ? `~${d.slice(home.length)}` : d;
|
|
9408
|
+
}
|
|
9409
|
+
function renderCtxBar(){
|
|
9410
|
+
const btn = $('ctxdirnow'); if (!btn) return;
|
|
9411
|
+
const proj = state.projects.find((p) => p.id === state.active);
|
|
9412
|
+
const dir = String(proj?.dir ?? '');
|
|
9413
|
+
// The repo's NAME, the way Claude prints it — the full path lives in the menu and the
|
|
9414
|
+
// tooltip. No folder yet is a real state, not a blank: say so rather than render empty.
|
|
9415
|
+
// folderName is shared with the menu rows below (engine/lib.mjs): two copies of "take
|
|
9416
|
+
// the last segment" is how a chip and its own menu start disagreeing.
|
|
9417
|
+
btn.textContent = dir ? (folderName(dir) || folderShort(dir)) : 'choose a folder';
|
|
9418
|
+
$('ctxdirbtn').title = dir || 'Choose the folder this project works in';
|
|
9419
|
+
// Branch: only when the engine actually read one off the folder (a folder that is not
|
|
9420
|
+
// a repo has none, and an invented branch would be worse than no chip at all).
|
|
9421
|
+
const branch = String(proj?.branch ?? '');
|
|
9422
|
+
const grp = $('ctxbrgrp');
|
|
9423
|
+
if (grp) grp.style.display = branch ? '' : 'none';
|
|
9424
|
+
const bn = $('ctxbranchname'); if (bn) bn.textContent = branch;
|
|
9425
|
+
renderWorktreeChip();
|
|
9426
|
+
const menu = $('ctxdirmenu'); if (!menu) return;
|
|
9427
|
+
const cur = dir;
|
|
9428
|
+
const opts = (state.repos ?? [])
|
|
9429
|
+
.map((r) => `<button type="button" class="appopt${r.dir === cur ? ' active' : ''}" data-ctxdir="${esc(r.dir)}"`
|
|
9430
|
+
+ ` title="${esc(r.dir)}"><span class="nm">${esc(folderName(r.dir))}</span>`
|
|
9431
|
+
// LRM, so the rtl clipping trims the HEAD of the path without reversing the text
|
|
9432
|
+
+ `<span class="pt">\u200e${esc(folderShort(r.dir))}</span></button>`)
|
|
9433
|
+
.join('');
|
|
9434
|
+
menu.innerHTML = `${opts}<div class="ctxpath"><input type="text" id="ctxdirin" placeholder="~/path/to/repo" aria-label="Folder path" spellcheck="false"><button type="button" class="appopt" id="ctxdiruse" style="width:auto">Use</button></div><div class="ctxmsg" id="ctxdirmsg"></div>`;
|
|
9435
|
+
}
|
|
9436
|
+
// The worktree checkbox (engine #268). ON — the default, and what every goal has always
|
|
9437
|
+
// done — gives each goal its own copy of the repo, so a run cannot leave this folder
|
|
9438
|
+
// half-edited. OFF sends worktree:false and the work happens right here, which is what
|
|
9439
|
+
// someone means when they want to watch it happen. Sticky per project, like model and
|
|
9440
|
+
// effort, because it is a working habit rather than a per-message thought.
|
|
9441
|
+
function worktreeKey(){ return `sel:worktree:${state.active}`; }
|
|
9442
|
+
function worktreeOn(){ return localStorage.getItem(worktreeKey()) !== '0'; }
|
|
9443
|
+
function renderWorktreeChip(){
|
|
9444
|
+
const el = $('ctxwt'); if (!el) return;
|
|
9445
|
+
el.setAttribute('aria-checked', worktreeOn() ? 'true' : 'false');
|
|
9446
|
+
}
|
|
9447
|
+
// Asked for once, and only when the menu is first opened — the same "only when you look
|
|
9448
|
+
// at it" rule the implementation log and the conversation follow.
|
|
9449
|
+
async function loadRepos(){
|
|
9450
|
+
if (state.repos !== null) return;
|
|
9451
|
+
state.repos = [];
|
|
9452
|
+
try {
|
|
9453
|
+
const r = await fetch(withKey('/api/repos'));
|
|
9454
|
+
const body = r.ok ? await r.json() : {};
|
|
9455
|
+
state.repos = Array.isArray(body.repos) ? body.repos : [];
|
|
9456
|
+
state.repoHome = String(body.home ?? '');
|
|
9457
|
+
} catch { /* no list — the typed path still works, which is the honest fallback */ }
|
|
9458
|
+
renderCtxBar();
|
|
9459
|
+
}
|
|
9460
|
+
// Move this project. The engine refuses a folder where work cannot happen and says
|
|
9461
|
+
// which; that sentence is shown as-is, because "it didn't work" without the reason is
|
|
9462
|
+
// the failure this control exists to prevent.
|
|
9463
|
+
async function setProjectFolder(dir){
|
|
9464
|
+
const want = String(dir ?? '').trim();
|
|
9465
|
+
const msg = $('ctxdirmsg');
|
|
9466
|
+
const say = (t, bad) => { if (msg) { msg.textContent = t; msg.classList.toggle('bad', Boolean(bad)); } };
|
|
9467
|
+
if (!want) { say('Type a path, or pick one above.', true); return; }
|
|
9468
|
+
say('');
|
|
9469
|
+
try {
|
|
9470
|
+
const r = await fetch(withKey(`/api/projects/${encodeURIComponent(state.active)}`), {
|
|
9471
|
+
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ dir: want }),
|
|
9472
|
+
});
|
|
9473
|
+
const body = await r.json().catch(() => ({}));
|
|
9474
|
+
if (!r.ok) { say(body.error || 'That folder cannot be used.', true); return; }
|
|
9475
|
+
if (Array.isArray(body.projects)) state.projects = body.projects;
|
|
9476
|
+
$('ctxdirmenu').classList.remove('show');
|
|
9477
|
+
render();
|
|
9478
|
+
} catch { say('Could not reach the engine.', true); }
|
|
9479
|
+
}
|
|
9480
|
+
$('ctxdirbtn').onclick = (e) => {
|
|
9481
|
+
e.stopPropagation();
|
|
9482
|
+
const menu = $('ctxdirmenu');
|
|
9483
|
+
const opening = !menu.classList.contains('show');
|
|
9484
|
+
menu.classList.toggle('show');
|
|
9485
|
+
if (opening) { renderCtxBar(); loadRepos(); } // no-op once boot's fetch has landed
|
|
9486
|
+
};
|
|
9487
|
+
$('ctxwt').onclick = (e) => {
|
|
9488
|
+
e.stopPropagation();
|
|
9489
|
+
localStorage.setItem(worktreeKey(), worktreeOn() ? '0' : '1');
|
|
9490
|
+
renderWorktreeChip();
|
|
9491
|
+
};
|
|
9492
|
+
$('ctxdirmenu').addEventListener('click', (e) => {
|
|
9493
|
+
e.stopPropagation();
|
|
9494
|
+
const opt = e.target.closest('[data-ctxdir]');
|
|
9495
|
+
if (opt) { setProjectFolder(opt.dataset.ctxdir); return; }
|
|
9496
|
+
if (e.target.closest('#ctxdiruse')) setProjectFolder($('ctxdirin')?.value ?? '');
|
|
9497
|
+
});
|
|
9498
|
+
$('ctxdirmenu').addEventListener('keydown', (e) => {
|
|
9499
|
+
e.stopPropagation();
|
|
9500
|
+
if (e.key === 'Enter' && e.target.id === 'ctxdirin') { e.preventDefault(); setProjectFolder(e.target.value); }
|
|
9501
|
+
});
|
|
8711
9502
|
$('appbtn').onclick = (e) => { e.stopPropagation(); $('appmenu').classList.toggle('show'); };
|
|
8712
9503
|
$('appmenu').addEventListener('click', (e) => {
|
|
8713
9504
|
const opt = e.target.closest('.appopt');
|
|
@@ -8727,6 +9518,10 @@ $('appmenu').addEventListener('click', (e) => {
|
|
|
8727
9518
|
});
|
|
8728
9519
|
document.addEventListener('click', (e) => {
|
|
8729
9520
|
if (!e.target.closest('.appsel')) $('appmenu').classList.remove('show');
|
|
9521
|
+
// Same rule for the folder menu: a click anywhere else closes it (§: settings-style
|
|
9522
|
+
// popovers close on outside tap — measured against composedPath, not target alone,
|
|
9523
|
+
// so a click inside the menu's own input never closes it).
|
|
9524
|
+
if (!e.composedPath().some((n) => n?.id === 'ctxdirsel')) $('ctxdirmenu').classList.remove('show');
|
|
8730
9525
|
});
|
|
8731
9526
|
$('modelbtn').onclick = (e) => { e.stopPropagation(); $('modelmenu').classList.toggle('show'); };
|
|
8732
9527
|
$('modelmenu').addEventListener('click', (e) => {
|