@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 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:9px;align-items:baseline}
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(--ink3);font-style:normal;line-height:1;transition:transform .14s}
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);padding-top:1px}
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
- requirePR: true, requireVerifyPass: false, description: '', defaultWantsPR: false, language: 'auto',
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
- // (P1, Masa 2026-07-21) The before/after diff-box machinery (computeDiffBox,
3762
- // baLoadImage, baZoomDataUrl, beforeAfterBoxedHtml) was deleted here. It pixel-diffed a
3763
- // "before" capture against an "after" one to draw a red box around what changed real
3764
- // craft, built on a pair that the engine no longer stages (#228) and that never existed
3765
- // outside this repo anyway. With no before shot there is nothing to compare.
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
- const r = await fetch(withKey(`/api/goals/${gid}/dismiss`), { method: 'POST',
4495
- headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }) });
4496
- if (!r.ok) showErr('送信に失敗しました'); else { inp.value = ''; state.qreplyOpen.delete(Number(gid)); await refresh(); }
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
- // prSections/reviewCard are nested objects — a shallow `{ ...def }` would
4786
- // share them by reference, so toggling one checkbox here would mutate
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 them one level deeper.
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">Read and followed by the worker when a goal reaches Review e.g. tell it to open a PR here instead of using a separate PR toggle.</span>
4812
- <textarea class="rdtext" id="rdDescription" maxlength="500" rows="2" placeholder="例:「英語でプルリクエストを作成して」など、ゴールの完了定義を書く">${esc(state.rdDraft.description ?? '')}</textarea></span></label>
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 r = await fetch(withKey(`/api/goals/${goal.id}/dismiss`), { method: 'POST',
5359
- headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: fullText }) });
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
- closeReviewChecklist();
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 r = await fetch(withKey(`/api/goals/${goalId}/dismiss`), { method: 'POST',
5589
- headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }) });
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
- // Context: closed by default, one-line verbatim-quote summary, chevron large and at
8115
- // the far right (H23 §2). Quotes goal.text the user's own words never
8116
- // task.detail/title (AI-authored; an earlier round of this design mistakenly quoted
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}. Word-level highlight is
8140
- // NOT fabricated from a raw diff (the product never invents evidence) — the Bright
8141
- // line+gutter tint carries the change and .gh-hi simply stays absent. Pure, so it is
8142
- // extracted and unit-tested in engine/test/review-card-diff.test.mjs.
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
- function saGhDiffHtml(diff){
8159
- const rows = saUnifiedToGh(diff);
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
- return saGhDiffRowsHtml(rows);
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 (l.h) { const h = esc(l.h); code = code.replace(h, `<span class="gh-hi">${h}</span>`); }
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
- const steps = [['Implemented', 'ok'], ['Tested', ran ? 'ok' : 'todo'], ['In review', 'now'], ['Merge', 'todo'], ['Deploy', 'todo']];
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
- // Conversational task (no code change): drops Proof + What changed, shows an Answer
8254
- // instead (spec §11); its Activity IS the conversation.
8255
- const isConv = Boolean(it.answer);
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: an open localhost URL (neutral, not green). A bare host:port isn't navigable in
8273
- // the demo, so it renders as a non-navigating chip; a real URL opens in a new tab.
8274
- if (it.localUrl) { const lu = String(it.localUrl); const isUrl = /^https?:/.test(lu);
8275
- chips.push(`<a class="mx mlink"${isUrl ? ` href="${esc(lu)}" target="_blank" rel="noopener"` : ' style="cursor:pointer"'} title="Open the running app"><span class="mk">Local</span> <b>${esc(lu.replace(/^https?:\/\//, ''))}</b>${arw}</a>`); }
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
- const openBody = it.run
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
- const discArr = it.discussion || it.thread || null;
8377
- const threadHtml = (discArr && discArr.length)
8378
- ? discArr.map((m) => `<div class="rmsg${m[0] === 'you' ? ' you' : ''}"><div class="who">${m[0] === 'you' ? 'You' : 'Manager'}</div><div class="msg">${esc(m[1])}</div></div>`).join('')
8379
- : '';
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 saAct(it, 3, 'dismiss', { text }).then((ok) => { if (ok) saAdvanceFocus(it.goalId); });
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) saAct(it, 3, 'dismiss', { text }).then((ok) => { if (ok) saAdvanceFocus(it.goalId); }); // no extra confirm (PRD §5.1): Enter = sent back
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) => {