@galda/cli 0.10.40 → 0.10.42

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}
@@ -1159,6 +1218,14 @@
1159
1218
  .gearpop .srow.dis{opacity:.38;pointer-events:none}
1160
1219
  .gearpop .srow .sl{flex:1;min-width:0;font-size:12.5px;color:var(--ink)}
1161
1220
  .gearpop .srow .sd{display:block;font-size:10.5px;color:var(--ink3);margin-top:2px}
1221
+ /* Project rules read-only view (§1.5a): a quiet framed panel under its row —
1222
+ mono filename label + the file body, scrollable. Reuses --canvas/--hair so it
1223
+ matches the MCP box; no new surface invented. */
1224
+ .gearpop .rulesview{margin:2px 0 9px;display:flex;flex-direction:column;gap:8px}
1225
+ .gearpop .rulesfile{background:var(--canvas);border-radius:8px;overflow:hidden}
1226
+ .gearpop .rulesname{font:600 9px/1 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--ink3);padding:8px 10px 0}
1227
+ .gearpop .rulestext{margin:6px 0 0;padding:0 10px 10px;max-height:220px;overflow:auto;font:400 11px/1.6 var(--mono);color:var(--ink2);white-space:pre-wrap;word-break:break-word}
1228
+ .gearpop .rulesempty{color:var(--ink3);font-style:italic}
1162
1229
  /* MCP tab: live status dot + connect command + quiet explanation */
1163
1230
  .gearpop .mcpstat{display:flex;align-items:center;gap:8px;font:600 11px/1 var(--ui);color:var(--ink);padding:2px 0 10px}
1164
1231
  .gearpop .mcpstat i{width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 6px rgba(var(--green-rgb),.5)}
@@ -2133,6 +2200,23 @@
2133
2200
  /* Manager's own progress notes inside the implementation log: commentary, not results.
2134
2201
  Green would read as "passed" (§5.5 UIクロームは薄く・優しく・主張しない). */
2135
2202
  .redesign .txr.note .m{font-family:var(--mono);font-size:12px;color:var(--ink3)}
2203
+ /* Conversation (ONE-CONVERSATION §1.1): the same transcript rows, so several
2204
+ entrances read as ONE column — no second box, no bubbles (§1.5-1 器を増やさない).
2205
+ Where a line was said is a hairline-quiet label, never a chip: it is context for
2206
+ the sentence, not a category of its own (§5.5 薄い要素も見えるが騒がない). */
2207
+ /* The entrance is CONTEXT for a sentence, not a category of it — so it is not a column
2208
+ (CDO handoff 2026-07-23 §B1). As a column it repeated 'chat' three times in five rows
2209
+ and pushed the body 116px right, so the same card had two left edges for the same kind
2210
+ of row. At the end of the line, only when it changed, the body lines up with Activity
2211
+ and the one thing worth knowing survives. */
2212
+ .redesign .txr .cvia{flex:0 0 auto;font-size:11px;color:var(--ink3);white-space:nowrap;padding-top:1px}
2213
+ /* "+N more" borrows the log's own quiet row (mono, --ink3) rather than inventing a
2214
+ control: nothing was summarised away, so pressing it only stops hiding (§B3). */
2215
+ .redesign .txr.convmore{cursor:pointer}
2216
+ .redesign .txr.convmore .m{font-family:var(--mono);font-size:12px;color:var(--ink3)}
2217
+ .redesign .txr.convmore:hover .m{color:var(--ink2)}
2218
+ .redesign .rconv .lbody .rtx{max-width:760px}
2219
+ .redesign .txr.mgr .m{color:var(--ink3)}
2136
2220
  /* Discussion (review back-and-forth) folded at the bottom of Activity's body */
2137
2221
  .redesign .lbody .rthread{display:flex;flex-direction:column;gap:16px;margin:0}
2138
2222
  .redesign .lbody .rmsg .who{font-size:12px;font-weight:600;color:var(--ink);margin-bottom:4px}
@@ -2800,6 +2884,26 @@
2800
2884
  <div class="errbar" id="errbar"></div>
2801
2885
  <div class="composer-wrap" id="composerWrap">
2802
2886
  <div class="queue" id="queue"></div>
2887
+ <!-- Context row (Masa 2026-07-22: 「claude の表記に合わせて」). The Claude app
2888
+ puts separate rounded chips above the box — repo · branch|worktree · add —
2889
+ rather than one continuous strip, and the words are its words. `Local` is
2890
+ deliberately absent: Claude offers it because it also runs in the cloud,
2891
+ and a chip that cannot be switched is decoration (§1.5-3). -->
2892
+ <div class="ctxbar" id="ctxbar">
2893
+ <div class="ctxsel ctxchip" id="ctxdirsel">
2894
+ <button type="button" class="ctxseg" id="ctxdirbtn" title="Folder this project works in">
2895
+ <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>
2896
+ <div class="appmenu ctxmenu" id="ctxdirmenu"></div>
2897
+ </div>
2898
+ <div class="ctxchip ctxbrgrp" id="ctxbrgrp">
2899
+ <span class="ctxseg ctxbranch" id="ctxbranch" title="Branch this folder is on">
2900
+ <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>
2901
+ <span class="ctxdiv"></span>
2902
+ <button type="button" class="ctxseg ctxwt" id="ctxwt" role="checkbox" aria-checked="true"
2903
+ title="On: each goal works in its own copy, so this folder is never touched. Off: the work happens right here.">
2904
+ <i class="ctxbox"></i>worktree</button>
2905
+ </div>
2906
+ </div>
2803
2907
  <!-- Conversation-first composer (SLICE 2, docs/HANDOFF-ONBOARDING-conversational.md):
2804
2908
  a chat/help/settings message gets a quiet reply HERE instead of
2805
2909
  becoming a To-Do. Reuses the .peraanswer surface; hidden until a
@@ -3180,7 +3284,7 @@ const savedTheme = (() => {
3180
3284
  if (v !== s) localStorage.setItem('theme', v);
3181
3285
  return v;
3182
3286
  })();
3183
- 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,
3287
+ 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, projectRules: {}, rulesOpen: false,
3184
3288
  // per-message send overrides set via slash-commands (reset after each send,
3185
3289
  // except model which is sticky in localStorage). palette = open command list.
3186
3290
  msg: { mode: null, model: null, effort: null, skill: null, later: false, review: false },
@@ -3370,9 +3474,12 @@ function prioBadge(t){
3370
3474
 
3371
3475
  // Mirrors engine/lib.mjs DEFAULT_REVIEW_DEFINITION — kept in sync manually,
3372
3476
  // see the comment on DEFAULT_WORKFLOW_COLUMNS above.
3477
+ // `requirePR` and `prSections` are deliberately absent: the engine stores them
3478
+ // but no code has ever read them, so the switches that wrote them are gone
3479
+ // (2026-07-22). The engine-side fields go in a follow-up; nothing here reads
3480
+ // them in the meantime.
3373
3481
  const DEFAULT_REVIEW_DEFINITION = {
3374
- requirePR: true, requireVerifyPass: false, description: '', defaultWantsPR: false, language: 'auto',
3375
- prSections: { summary: true, whatChanged: true, howToReview: true, tests: true, screenshots: true, risk: true },
3482
+ requireVerifyPass: false, description: '', defaultWantsPR: false, language: 'auto',
3376
3483
  reviewCard: { screenshots: true, beforeAfter: false },
3377
3484
  };
3378
3485
  // Shown (faint) under Review when no definition is set yet, so it's obvious the
@@ -4602,9 +4709,14 @@ function renderTasks(){
4602
4709
  // the rework task ("reworking: <goal>").
4603
4710
  const sendQReply = async (gid, inp) => {
4604
4711
  const text = inp.value.trim(); if (!text) return;
4605
- const r = await fetch(withKey(`/api/goals/${gid}/dismiss`), { method: 'POST',
4606
- headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }) });
4607
- if (!r.ok) showErr('送信に失敗しました'); else { inp.value = ''; state.qreplyOpen.delete(Number(gid)); await refresh(); }
4712
+ try {
4713
+ const body = await replyToGoal(gid, text, `[data-qreplyinput="${gid}"]`);
4714
+ if (!body) return; // asked, and nobody chose leave the words in the box
4715
+ inp.value = '';
4716
+ // A question keeps the row open: the answer lands under this very box.
4717
+ if (body.outcome !== 'answer') state.qreplyOpen.delete(Number(gid));
4718
+ await refresh();
4719
+ } catch { showErr('送信に失敗しました'); }
4608
4720
  };
4609
4721
  for (const b of $('tasklist').querySelectorAll('[data-qreplysend]')) {
4610
4722
  b.onclick = (e) => { e.stopPropagation(); sendQReply(b.dataset.qreplysend, $('tasklist').querySelector(`[data-qreplyinput="${b.dataset.qreplysend}"]`)); };
@@ -4893,13 +5005,12 @@ function renderAttach(){
4893
5005
  function openWfEditor(){
4894
5006
  state.wfDraft = columnsFor(state.active).map((c) => ({ ...c }));
4895
5007
  const def = reviewDefinitionFor(state.active);
4896
- // prSections/reviewCard are nested objects — a shallow `{ ...def }` would
4897
- // share them by reference, so toggling one checkbox here would mutate
5008
+ // reviewCard is a nested object — a shallow `{ ...def }` would share it by
5009
+ // reference, so toggling one checkbox here would mutate
4898
5010
  // DEFAULT_REVIEW_DEFINITION (or another project's cached definition)
4899
- // instead of just this draft. Copy them one level deeper.
5011
+ // instead of just this draft. Copy it one level deeper.
4900
5012
  state.rdDraft = {
4901
5013
  ...def,
4902
- prSections: { ...DEFAULT_REVIEW_DEFINITION.prSections, ...(def.prSections ?? {}) },
4903
5014
  reviewCard: reviewCardConfigFor(state.active),
4904
5015
  };
4905
5016
  $('wferr').style.display = 'none';
@@ -4912,35 +5023,12 @@ function renderRdEditor(){
4912
5023
  <label class="rdrow"><input type="checkbox" id="rdDefaultWantsPR"${state.rdDraft.defaultWantsPR ? ' checked' : ''}>
4913
5024
  <span><span class="rdlabel">Open a GitHub pull request by default</span>
4914
5025
  <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>
4915
- <label class="rdrow"><input type="checkbox" id="rdRequirePR"${state.rdDraft.requirePR ? ' checked' : ''}>
4916
- <span><span class="rdlabel">Requires a pull request</span>
4917
- <span class="rdhint">A goal that asked for a PR always lands in Review, even if PR creation failed.</span></span></label>
4918
5026
  <label class="rdrow"><input type="checkbox" id="rdRequireVerify"${state.rdDraft.requireVerifyPass ? ' checked' : ''}>
4919
5027
  <span><span class="rdlabel">Requires an independent verify pass</span>
4920
5028
  <span class="rdhint">Any task whose headless-browser check didn't pass (or never ran) forces Review too.</span></span></label>
4921
5029
  <label class="rdrow rddesc"><span><span class="rdlabel">Review instructions</span>
4922
- <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>
4923
- <textarea class="rdtext" id="rdDescription" maxlength="500" rows="2" placeholder="例:「英語でプルリクエストを作成して」など、ゴールの完了定義を書く">${esc(state.rdDraft.description ?? '')}</textarea></span></label>
4924
-
4925
- <div class="rdsub">PR本文に含める項目 / PR body sections</div>
4926
- <label class="rdrow"><input type="checkbox" id="rdPrSummary"${state.rdDraft.prSections.summary ? ' checked' : ''}>
4927
- <span><span class="rdlabel">Summary</span>
4928
- <span class="rdhint">ゴールの目的(Goal本文)をPRに含める。</span></span></label>
4929
- <label class="rdrow"><input type="checkbox" id="rdPrWhatChanged"${state.rdDraft.prSections.whatChanged ? ' checked' : ''}>
4930
- <span><span class="rdlabel">What changed</span>
4931
- <span class="rdhint">変更ファイルの一覧をPRに含める。</span></span></label>
4932
- <label class="rdrow"><input type="checkbox" id="rdPrHowToReview"${state.rdDraft.prSections.howToReview ? ' checked' : ''}>
4933
- <span><span class="rdlabel">How to review</span>
4934
- <span class="rdhint">上のレビュー指示(Review instructions)をPR本文にも載せる。</span></span></label>
4935
- <label class="rdrow"><input type="checkbox" id="rdPrTests"${state.rdDraft.prSections.tests ? ' checked' : ''}>
4936
- <span><span class="rdlabel">Tests</span>
4937
- <span class="rdhint">Managerが実行したテスト結果をPRに含める。</span></span></label>
4938
- <label class="rdrow"><input type="checkbox" id="rdPrScreenshots"${state.rdDraft.prSections.screenshots ? ' checked' : ''}>
4939
- <span><span class="rdlabel">Screenshots</span>
4940
- <span class="rdhint">証跡(スクショ/GIF)へのリンクをPRに含める。</span></span></label>
4941
- <label class="rdrow"><input type="checkbox" id="rdPrRisk"${state.rdDraft.prSections.risk ? ' checked' : ''}>
4942
- <span><span class="rdlabel">Risk</span>
4943
- <span class="rdhint">注意が必要そうな変更範囲を一言添える。</span></span></label>
5030
+ <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>
5031
+ <textarea class="rdtext" id="rdDescription" maxlength="500" rows="2" placeholder="例:「変更前後のスクショを見比べて、余白が揃っているか確認してください」など、レビューする人に伝えたいこと">${esc(state.rdDraft.description ?? '')}</textarea></span></label>
4944
5032
 
4945
5033
  <div class="rdsub">レビューカードに出す項目 / Review card fields</div>
4946
5034
  <label class="rdrow"><input type="checkbox" id="rdCardScreenshots"${state.rdDraft.reviewCard.screenshots ? ' checked' : ''}>
@@ -4956,15 +5044,8 @@ function renderRdEditor(){
4956
5044
  <span><span class="rdlabel">Review language</span>
4957
5045
  <span class="rdhint">レビュー要約(確認/変更)などManagerが生成する文言の言語。</span></span></label>`;
4958
5046
  $('rdDefaultWantsPR').addEventListener('change', (e) => { state.rdDraft.defaultWantsPR = e.target.checked; });
4959
- $('rdRequirePR').addEventListener('change', (e) => { state.rdDraft.requirePR = e.target.checked; });
4960
5047
  $('rdRequireVerify').addEventListener('change', (e) => { state.rdDraft.requireVerifyPass = e.target.checked; });
4961
5048
  $('rdDescription').addEventListener('input', (e) => { state.rdDraft.description = e.target.value; });
4962
- $('rdPrSummary').addEventListener('change', (e) => { state.rdDraft.prSections.summary = e.target.checked; });
4963
- $('rdPrWhatChanged').addEventListener('change', (e) => { state.rdDraft.prSections.whatChanged = e.target.checked; });
4964
- $('rdPrHowToReview').addEventListener('change', (e) => { state.rdDraft.prSections.howToReview = e.target.checked; });
4965
- $('rdPrTests').addEventListener('change', (e) => { state.rdDraft.prSections.tests = e.target.checked; });
4966
- $('rdPrScreenshots').addEventListener('change', (e) => { state.rdDraft.prSections.screenshots = e.target.checked; });
4967
- $('rdPrRisk').addEventListener('change', (e) => { state.rdDraft.prSections.risk = e.target.checked; });
4968
5049
  $('rdCardScreenshots').addEventListener('change', (e) => { state.rdDraft.reviewCard.screenshots = e.target.checked; });
4969
5050
  $('rdLanguage').addEventListener('change', (e) => { state.rdDraft.language = e.target.value; });
4970
5051
  }
@@ -5456,6 +5537,171 @@ function renderReviewAttach(){
5456
5537
  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('');
5457
5538
  for (const b of bar.querySelectorAll('.rm')) b.onclick = () => { reviewAttach.splice(Number(b.dataset.ri), 1); renderReviewAttach(); };
5458
5539
  }
5540
+ // 返事は "auto" で送る (ONE-CONVERSATION §1.3/§1.4)。
5541
+ //
5542
+ // 行き先を決めるのは AI 本人で、この層は言われたとおりに配線するだけ。今までは中身に
5543
+ // 関わらず全部「直して」で、「なんでこうしたの?」と聞いただけでゴールが Doing に戻り
5544
+ // ワーカーが走っていた。人はモードを普段選ばない——AI が迷った時だけ選ぶ。
5545
+ const REPLY_CHOICE_LABEL = { continue: 'Fix this', rescope: 'Rethink it', split: 'Separate ticket', answer: 'Just a question' };
5546
+ // The one waiting line's two labels. Local to the reply note on purpose — STATUS_LABEL
5547
+ // is the task/workflow vocabulary and this is neither (CDO §5).
5548
+ const REPLY_WAIT_ROUTING = 'Thinking…';
5549
+ const REPLY_WAIT_ANSWERING = 'Answering…';
5550
+ async function postReply(goalId, text, outcome = 'auto'){
5551
+ const r = await fetch(withKey(`/api/goals/${goalId}/dismiss`), { method: 'POST',
5552
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, outcome }) });
5553
+ if (!r.ok) throw new Error();
5554
+ return r.json();
5555
+ }
5556
+ // One line under whichever reply box was used. It is held in a variable and
5557
+ // PAINTED after every render, because a reply writes to the goal, which arrives
5558
+ // back over SSE, which rebuilds the list — a node appended straight to the DOM
5559
+ // disappears a second later (and an answer nobody sees is the same as no answer).
5560
+ let replyNoteState = null; // { anchorSel, you, phase, html, resolve? }
5561
+ function paintReplyNote(){
5562
+ for (const stale of document.querySelectorAll('.replynote')) stale.remove();
5563
+ paintSettledThreads();
5564
+ if (!replyNoteState) return;
5565
+ const anchor = document.querySelector(replyNoteState.anchorSel);
5566
+ if (!anchor) return;
5567
+ for (const stale of document.querySelectorAll('.replynote')) stale.remove();
5568
+ const el = document.createElement('div');
5569
+ el.className = 'qsub replynote';
5570
+ el.innerHTML = replyNoteState.html;
5571
+ // BELOW the reply box, never beside it (§1.4). The review card's box lives in a
5572
+ // flex row of buttons — appending inside it puts the choices next to the send
5573
+ // arrow, on top of it at narrow widths.
5574
+ const row = anchor.closest('.sa-act') ?? anchor.parentElement;
5575
+ if (row?.parentElement) row.after(el); else row?.appendChild(el);
5576
+ for (const b of el.querySelectorAll('[data-rc]')) b.onclick = () => {
5577
+ const pick = b.dataset.rc, done = replyNoteState?.resolve;
5578
+ replyNoteState = null; paintReplyNote(); done?.(pick);
5579
+ };
5580
+ }
5581
+ // Every open card with past review turns shows them, whether or not a reply is in
5582
+ // flight — this is what makes "close it, come back tomorrow" work (CDO §3.1).
5583
+ function paintSettledThreads(){
5584
+ for (const inp of document.querySelectorAll('#seeall .sa-chat[data-goal]')) {
5585
+ const goalId = Number(inp.dataset.goal);
5586
+ loadReplyThread(goalId);
5587
+ if (replyNoteState?.goalId === goalId) continue; // the live note draws its own history
5588
+ const html = replyPastHtmlFor(goalId);
5589
+ if (!html) continue;
5590
+ const row = inp.closest('.sa-act') ?? inp.parentElement;
5591
+ if (!row?.parentElement) continue;
5592
+ const el = document.createElement('div');
5593
+ el.className = 'qsub replynote';
5594
+ el.innerHTML = html;
5595
+ row.after(el);
5596
+ }
5597
+ }
5598
+ function setReplyNote(anchorSel, html, resolve){
5599
+ replyNoteState = anchorSel ? { ...replyNoteState, anchorSel, html, resolve } : null;
5600
+ paintReplyNote();
5601
+ }
5602
+ // Past turns of this card's reply thread, by goal id. The exchange is not a session
5603
+ // thing: every message this flow writes is on the goal (`goal.messages`, via:'review'),
5604
+ // so reopening the card next week must show it exactly as it settled (CDO §3.1).
5605
+ // Fetched once per goal when the card renders; the live state machine in
5606
+ // replyNoteState only ever covers a reply that is still in flight right now.
5607
+ const replyThread = {}; // goalId -> [{from, via, text}]
5608
+ const replyThreadLoading = new Set();
5609
+ function loadReplyThread(goalId){
5610
+ if (replyThread[goalId] || replyThreadLoading.has(goalId)) return;
5611
+ replyThreadLoading.add(goalId);
5612
+ fetch(withKey(`/api/goals/${goalId}/messages`))
5613
+ .then((r) => (r.ok ? r.json() : { messages: [] }))
5614
+ .then((b) => {
5615
+ replyThread[goalId] = (b.messages ?? []).filter((m) => m.via === 'review');
5616
+ if (replyThread[goalId].length) saRender();
5617
+ })
5618
+ .catch(() => { replyThread[goalId] = []; })
5619
+ .finally(() => replyThreadLoading.delete(goalId));
5620
+ }
5621
+ // Settled history: no shimmer, nothing pending — this already happened.
5622
+ function replyPastHtml(){
5623
+ const goalId = replyNoteState?.goalId;
5624
+ return replyPastHtmlFor(goalId);
5625
+ }
5626
+ function replyPastHtmlFor(goalId){
5627
+ const msgs = goalId == null ? [] : (replyThread[goalId] ?? []);
5628
+ return msgs.map((m) => (m.from === 'you' ? replySaidHtml(m.text)
5629
+ : m.from === 'ai' ? replyAnsweredHtml(m.text)
5630
+ : replyNoteRowHtml(esc(m.text)))).join('');
5631
+ }
5632
+
5633
+ // The reply box's thread rows. NOT the centre feed's .cc-you/.cc-say: every rule for
5634
+ // that vocabulary is scoped to #fsRoot, and the review card lives in #seeall — the
5635
+ // markup rendered there came out as unstyled black text (CDO decision §1). The card
5636
+ // already draws You/Claude rows for Activity with `.txr`, correctly scoped and
5637
+ // weighted, so this reuses those instead of defining anything new.
5638
+ function replySaidHtml(you){
5639
+ return `<div class="txr you"><span class="w">You</span><span class="m">${esc(you)}</span></div>`;
5640
+ }
5641
+ // One indicator that changes its label — never a second spinner (CDO decision §2, Q2).
5642
+ function replyWaitHtml(label){
5643
+ return `<div class="txr note"><span class="w"></span><span class="m"><span class="shimmer">${esc(label)}</span></span></div>`;
5644
+ }
5645
+ function replyNoteRowHtml(body){
5646
+ return `<div class="txr note"><span class="w"></span><span class="m">${body}</span></div>`;
5647
+ }
5648
+ function replyAnsweredHtml(text){
5649
+ return `<div class="txr cl"><span class="w">Claude</span><span class="m">${esc(text)}</span></div>`;
5650
+ }
5651
+ // Sent, and nothing decided yet. Shown immediately — the wait is real (a routing
5652
+ // call, then the answer), so the honest thing is to say it is thinking, not to
5653
+ // pretend the reply landed somewhere it has not.
5654
+ function showReplySent(anchorSel, you, goalId){
5655
+ replyNoteState = { anchorSel, you, goalId, phase: 'sent' };
5656
+ setReplyNote(anchorSel, replyPastHtml() + replySaidHtml(you) + replyWaitHtml(REPLY_WAIT_ROUTING));
5657
+ }
5658
+ // AI said it cannot tell (§1.4): the goal has NOT been touched, so this asks and
5659
+ // sends again with the chosen destination. Leaving it unanswered changes nothing.
5660
+ function askReplyDestination(anchorSel, choices){
5661
+ return new Promise((resolve) => {
5662
+ const said = replyNoteState?.you ? replySaidHtml(replyNoteState.you) : '';
5663
+ // The You row stays above the chips so "this" has something to point at (CDO Q5).
5664
+ setReplyNote(anchorSel, replyPastHtml() + said + replyNoteRowHtml('Which one is this?')
5665
+ + `<div class="qchips">${(choices || []).map((c) =>
5666
+ `<button type="button" class="qchip" data-rc="${esc(c)}">${esc(REPLY_CHOICE_LABEL[c] ?? c)}</button>`).join('')}</div>`, resolve);
5667
+ });
5668
+ }
5669
+ // answer (§1.3): nothing was built, so there is no task to watch — the reply comes
5670
+ // back as a message on the goal. Show it where the question was asked, so an answer
5671
+ // cannot arrive somewhere nobody is looking.
5672
+ async function showAnswerWhenItArrives(anchorSel, goalId, seen){
5673
+ const mine = () => replyPastHtml() + (replyNoteState?.you ? replySaidHtml(replyNoteState.you) : '');
5674
+ setReplyNote(anchorSel, mine() + replyWaitHtml(REPLY_WAIT_ANSWERING));
5675
+ for (let i = 0; i < 120; i++) {
5676
+ await new Promise((r) => setTimeout(r, 1000));
5677
+ let msgs = [];
5678
+ try { msgs = (await (await fetch(withKey(`/api/goals/${goalId}/messages`))).json()).messages ?? []; } catch { continue; }
5679
+ const said = msgs.slice(seen).find((m) => m.from === 'ai' || m.from === 'manager');
5680
+ if (said) {
5681
+ // It is history now — render it exactly as a reopened card will (CDO §3.1), and
5682
+ // keep it anchored where it was asked, so this works in the list's reply box too
5683
+ // (that one is not inside #seeall and would otherwise lose the answer).
5684
+ replyThread[goalId] = msgs.filter((m) => m.via === 'review');
5685
+ return setReplyNote(anchorSel, replyPastHtmlFor(goalId));
5686
+ }
5687
+ }
5688
+ setReplyNote(anchorSel, mine() + replyNoteRowHtml('No answer came back.'));
5689
+ }
5690
+ // The whole reply flow in one place: send as auto, ask if the AI was unsure, and
5691
+ // keep an answer visible. Returns the settled body, or null if the person cancelled.
5692
+ async function replyToGoal(goalId, text, anchorSel){
5693
+ const seen = state.goals.find((g) => g.id === Number(goalId))?.messageCount ?? 0;
5694
+ showReplySent(anchorSel, text, Number(goalId)); // before the round trip: the words stay, and it says it is thinking
5695
+ let body = await postReply(goalId, text, 'auto');
5696
+ if (body.outcome === 'unsure') {
5697
+ const picked = await askReplyDestination(anchorSel, body.choices);
5698
+ if (!picked) return null;
5699
+ body = await postReply(goalId, text, picked);
5700
+ }
5701
+ // seen + 1: the question itself was just recorded; the answer is the one after it.
5702
+ if (body.outcome === 'answer') showAnswerWhenItArrives(anchorSel, goalId, seen + 1);
5703
+ return body;
5704
+ }
5459
5705
  async function sendReviewDismiss(text){
5460
5706
  const goal = state.goals.find((g) => g.id === state.reviewOpenGoal);
5461
5707
  if (!goal) return;
@@ -5466,15 +5712,15 @@ async function sendReviewDismiss(text){
5466
5712
  ? `${text}${text ? '\n\n' : ''}添付画像(workerはこのパスをReadで確認できます): ${imgs.map((a) => a.path).join(', ')}`
5467
5713
  : text;
5468
5714
  try {
5469
- const r = await fetch(withKey(`/api/goals/${goal.id}/dismiss`), { method: 'POST',
5470
- headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: fullText }) });
5471
- if (!r.ok) throw new Error();
5472
- const body = await r.json();
5715
+ const body = await replyToGoal(goal.id, fullText, '#reviewFixInput');
5716
+ if (!body) return; // unsure, and nobody chose
5473
5717
  upsert(state.goals, body.goal);
5474
5718
  if (body.task) upsert(state.tasks, body.task);
5475
5719
  $('reviewFixInput').value = '';
5476
5720
  reviewAttach = []; renderReviewAttach();
5477
- closeReviewChecklist();
5721
+ // A question changed nothing, so closing the card would hide the answer that is
5722
+ // on its way. Only a reply that actually moved the goal closes it.
5723
+ if (body.outcome !== 'answer') closeReviewChecklist();
5478
5724
  render();
5479
5725
  } catch { showErr('Dismiss failed.'); }
5480
5726
  }
@@ -5696,14 +5942,12 @@ async function sendReject(goalId){
5696
5942
  async function sendDismiss(goalId, text){
5697
5943
  $('gdApprove').disabled = true; $('gdDismiss').disabled = true; $('gdSend').disabled = true;
5698
5944
  try {
5699
- const r = await fetch(withKey(`/api/goals/${goalId}/dismiss`), { method: 'POST',
5700
- headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }) });
5701
- if (!r.ok) throw new Error();
5702
- const body = await r.json();
5945
+ const body = await replyToGoal(goalId, text, '#gdRevisionInput');
5946
+ if (!body) { $('gdApprove').disabled = false; $('gdDismiss').disabled = false; $('gdSend').disabled = false; return; }
5703
5947
  upsert(state.goals, body.goal);
5704
5948
  upsert(state.tasks, body.task);
5705
5949
  $('gdRevisionInput').value = '';
5706
- closeGoalDetail();
5950
+ if (body.outcome !== 'answer') closeGoalDetail(); // a question leaves the card open for its answer
5707
5951
  render();
5708
5952
  } catch { showErr('Dismiss failed.'); }
5709
5953
  $('gdApprove').disabled = false; $('gdDismiss').disabled = false; $('gdSend').disabled = false;
@@ -5759,11 +6003,13 @@ $('gdRevisionInput').addEventListener('keydown', (e) => {
5759
6003
 
5760
6004
  function render(){
5761
6005
  renderProjects(); renderStream(); renderQueue(); renderTasks(); renderActPanel(); wireWfEditButtons();
6006
+ renderCtxBar(); // the folder above the composer follows the active project
5762
6007
  renderFlagship();
5763
6008
  if (state.reviewOpenGoal != null) renderReviewChecklist();
5764
6009
  if (state.goalDetailOpen != null) renderGoalDetail();
5765
6010
  if (typeof SA !== 'undefined' && SA.open) saSync(); // keep the open See all Ledger live
5766
6011
  const bv = $('botver'); if (bv && state.uiVersion) bv.textContent = 'build ' + String(state.uiVersion).slice(0, 8);
6012
+ paintReplyNote(); // a reply's answer/question survives the re-render it caused
5767
6013
  }
5768
6014
 
5769
6015
  // ============================================================================
@@ -6093,8 +6339,9 @@ function fsBoardBucket(goal, tasks){
6093
6339
  if (s === 'needsInput') return 'needs'; // a clarifying question is waiting on the user
6094
6340
  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へ移動しない)
6095
6341
  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)
6096
- if (s === 'review') return 'review';
6342
+ if (s === 'review' || s === 'folded') return 'review'; // folded (PR filed for review) reads as Review, not Doing — goalGroupStatus parity (Masa 2026-07-21)
6097
6343
  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)
6344
+ 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.
6098
6345
  if (s === 'stacked' || s === 'sending') return 'todo';
6099
6346
  if (s === 'running' && !goal?.startedAt) return 'todo'; // goalChip: planned, nothing started yet
6100
6347
  // Otherwise the goal is in flight (planning / retesting / running-and-started). BUT a
@@ -6217,8 +6464,8 @@ function renderFsBoard(){
6217
6464
  return `<div class="lbc${quiet ? ' is-quiet' : ''}">${h}</div>`;
6218
6465
  };
6219
6466
  const no = (g) => goalNo.get(g.id) ?? Infinity;
6220
- const bucket = { todo: [], doing: [], needs: [], review: [], done: [], later: [] };
6221
- for (const g of pgoals) bucket[fsBoardBucket(g, list)].push(g);
6467
+ const bucket = { todo: [], doing: [], needs: [], review: [], done: [], later: [], rejected: [] };
6468
+ 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
6222
6469
  for (const k of Object.keys(bucket)) bucket[k].sort((a, b) => k === 'done' ? no(b) - no(a) : no(a) - no(b));
6223
6470
  // To Do / Doing / Needs you / Review / Done / Later — the original design's columns
6224
6471
  // (Masa 2026-07-16). "Needs you" = a clarifying question is waiting on the user —
@@ -6403,6 +6650,10 @@ function fsBucketTodo(){
6403
6650
  const list = state.tasks.filter((t) => t.projectId === state.active);
6404
6651
  const order = state.queueOrder[state.active] ?? [];
6405
6652
  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; };
6653
+ // A closed (rejected) goal's tasks stay 'done' after Reject, so they used to leak into the
6654
+ // "✓ Done" fold below AND show in the Dismissed fold — the same closed todo listed twice.
6655
+ // Its home is the Dismissed fold; keep its done tasks out of Done (Masa 2026-07-21).
6656
+ const closedGoalIds = new Set(pgoals.filter((g) => g.status === 'rejected').map((g) => g.id));
6406
6657
  return {
6407
6658
  pgoals, list,
6408
6659
  running: list.filter((t) => t.status === 'running'),
@@ -6411,7 +6662,7 @@ function fsBucketTodo(){
6411
6662
  needsInput: pgoals.filter((g) => g.status === 'needsInput'), // a clarifying question is waiting on the user (board's "Needs you")
6412
6663
  reviewGoals: pgoals.filter((g) => g.status === 'review'),
6413
6664
  queued: list.filter((t) => t.status === 'queued' && !t.reply).sort(byQueue),
6414
- doneAll: list.filter((t) => ['done', 'skipped'].includes(t.status)).sort((a, b) => b.num - a.num),
6665
+ doneAll: list.filter((t) => ['done', 'skipped'].includes(t.status) && !closedGoalIds.has(t.goalId)).sort((a, b) => b.num - a.num),
6415
6666
  later: pgoals.filter((g) => g.status === 'pending'),
6416
6667
  rejected: pgoals.filter((g) => g.status === 'rejected'), // 07-20c: Reject parks goals here (own fold, like Done)
6417
6668
  };
@@ -7730,6 +7981,12 @@ async function boot(){
7730
7981
  if (window.__GALDA_DISCONNECTED) return bootDisconnected(window.__GALDA_DISCONNECTED);
7731
7982
  $('input').value = localStorage.getItem('draft') ?? '';
7732
7983
  await refresh();
7984
+ // The folder sits above the composer and is read on every screen, so its list is
7985
+ // fetched once at boot rather than on first open: without HOME the bar can only
7986
+ // print the absolute path (measured — it read "/var/folders/…/agent-manager"
7987
+ // instead of "~/agent-manager" until the menu had been opened once). One read of
7988
+ // one directory, not the 500KB-per-task class the on-open rule exists for.
7989
+ loadRepos();
7733
7990
  flushOutbox();
7734
7991
  connectSSE();
7735
7992
  startHealthPoll();
@@ -7770,7 +8027,7 @@ async function postOutboxItem(item){
7770
8027
  posting.add(item.oid);
7771
8028
  try {
7772
8029
  const r = await fetch(withKey('/api/tasks'), { method: 'POST', headers: { 'content-type': 'application/json' },
7773
- 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 }) });
8030
+ 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 }) });
7774
8031
  if (r.status === 402) {
7775
8032
  const data = await r.json().catch(() => ({}));
7776
8033
  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
@@ -7942,6 +8199,9 @@ async function send(){
7942
8199
  oid: `o${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
7943
8200
  projectId: state.active, text, pr: 'auto', model, effort, agent: state.connectedApp,
7944
8201
  mode, skill: skill || null,
8202
+ // Only the OFF case travels: the engine stores an explicit false and leaves every
8203
+ // other goal meaning exactly what it meant (engine #268).
8204
+ worktree: worktreeOn() ? undefined : false,
7945
8205
  pending: later, review,
7946
8206
  images: attach.map((a) => a.path), imageUrls: attach.map((a) => a.url),
7947
8207
  status: 'sending', ts: Date.now(),
@@ -8103,7 +8363,7 @@ function closeSummaryPera(){ $('summaryOverlay').classList.remove('show'); }
8103
8363
  // 4 archived / 5 reverted — session-local; the server round-trip is what
8104
8364
  // settles it (see saSync's "returned to review" reset).
8105
8365
  // ============================================================================
8106
- const SA = { open: false, items: [], diffs: {}, actLog: {}, shotBox: {}, detOpen: new Set(), focusGid: null, focusScrolled: false, focusTimer: null, cur: null };
8366
+ const SA = { open: false, items: [], diffs: {}, actLog: {}, msgs: {}, convOpen: new Set(), shotBox: {}, detOpen: new Set(), focusGid: null, focusScrolled: false, focusTimer: null, cur: null };
8107
8367
  window.SA = SA; // top-level const isn't a window property; external checks probe window.SA (same convention as window.state above)
8108
8368
  const SA_SVG = {
8109
8369
  ok: '<svg viewBox="0 0 24 24"><path d="M4 12l5 5L20 6"/></svg>',
@@ -8169,6 +8429,11 @@ function saRows(){
8169
8429
  const ids = [...r.goalIds, ...folded.map((f) => f.id)];
8170
8430
  return ts.filter((t) => ids.includes(t.goalId)).flatMap((t) => t.shots ?? []);
8171
8431
  })(),
8432
+ // How many messages this goal's conversation holds (§1.1). A COUNT, not the
8433
+ // conversation: state carries every card on every tick, and 100 cards dragging
8434
+ // 100 threads is what the engine deliberately avoided. 0/undefined means the
8435
+ // section is not rendered at all (§1.5-3: no container for something absent).
8436
+ messageCount: Number(g?.messageCount) || 0,
8172
8437
  // The task whose implementation log the engine froze at completion (P1.5). The
8173
8438
  // lines are NOT in state — 1000 of them per task would bloat every page load — so
8174
8439
  // this is just the id to ask for when the reviewer opens Activity.
@@ -8243,10 +8508,18 @@ function saRows(){
8243
8508
  // has called it since the review-card redesign; Request and Result carry that ground now.)
8244
8509
  // Unified-diff → GitHub two-gutter model (REVIEW-CARD-SPEC §7 "GitHub diff = Bright"):
8245
8510
  // walk the @@ hunk headers to recover the old/new line numbers, emit one row per line
8246
- // {o:oldNo|null, n:newNo|null, t:'add'|'del'|'ctx', c:text}. Word-level highlight is
8247
- // NOT fabricated from a raw diff (the product never invents evidence) — the Bright
8248
- // line+gutter tint carries the change and .gh-hi simply stays absent. Pure, so it is
8249
- // extracted and unit-tested in engine/test/review-card-diff.test.mjs.
8511
+ // {o:oldNo|null, n:newNo|null, t:'add'|'del'|'ctx', c:text}. Pure, so it is extracted
8512
+ // and unit-tested in engine/test/review-card-diff.test.mjs.
8513
+ //
8514
+ // Word-level highlight (§7 "変わった単語だけ濃くハイライト", Masa 2026-07-22): the span
8515
+ // is MEASURED between the two lines the reviewer is already looking at — a common
8516
+ // prefix and suffix, with what is left over marked — never guessed. The earlier note
8517
+ // here said the highlight would be "fabricated evidence"; a character comparison of two
8518
+ // shown lines invents nothing. What WOULD be a guess is pairing the wrong removed line
8519
+ // with the wrong added one, so only an unambiguous pair is marked: a run of removals
8520
+ // immediately followed by a run of additions of the SAME length, paired in order. A
8521
+ // 3-for-2 replacement, an add with no matching del, a rewritten line where almost
8522
+ // nothing survives — all keep today's behaviour exactly (tint only, no span).
8250
8523
  // How many body rows the diff really has, ignoring the display cap. A big change used
8251
8524
  // to be cut at 80 rows with nothing said (SPEC §7 asks for "…+N 行 · GitHub で開く ↗"
8252
8525
  // precisely so a reviewer is never shown a slice that looks like the whole thing).
@@ -8277,8 +8550,61 @@ function saUnifiedToGh(diff, max){
8277
8550
  }
8278
8551
  return out;
8279
8552
  }
8553
+ // The changed span between one removed line and the line that replaced it: skip the
8554
+ // common head, skip the common tail, and what is left in the middle is what changed.
8555
+ // Returns the two offset pairs plus `kept` — how much of the longer line survived —
8556
+ // which is the evidence that these two lines ARE the same line. Null only when the
8557
+ // lines are identical (nothing to point at).
8558
+ function saWordSpan(delText, addText){
8559
+ const a = String(delText ?? ''), b = String(addText ?? '');
8560
+ if (a === b) return null;
8561
+ const max = Math.min(a.length, b.length);
8562
+ let p = 0;
8563
+ while (p < max && a[p] === b[p]) p++;
8564
+ let sfx = 0;
8565
+ while (sfx < max - p && a[a.length - 1 - sfx] === b[b.length - 1 - sfx]) sfx++;
8566
+ return { del: [p, a.length - sfx], add: [p, b.length - sfx], kept: (p + sfx) / Math.max(a.length, b.length, 1) };
8567
+ }
8568
+ // How much of a line must survive before two lines count as the same line. Below this
8569
+ // the "change" is a rewrite: marking almost the whole line says less than the Bright
8570
+ // tint already does, and a low score is also how an unrelated pair gets rejected.
8571
+ const SA_WORD_KEEP = 0.34;
8572
+ // Mark the changed span on removed/added pairs, in place.
8573
+ //
8574
+ // Pairing is the only place a guess could enter, so it is decided by measurement: each
8575
+ // removed line takes the added line it shares the most text with, and the pair is only
8576
+ // marked when that shared text clears SA_WORD_KEEP. Everything else — a pure addition,
8577
+ // a pure removal, a rewrite, an unrelated neighbour — is left exactly as it renders
8578
+ // today (tint only, no span).
8579
+ //
8580
+ // Measured on this product's own history (2026-07-22): the first rule written here
8581
+ // paired blocks in order and required equal lengths, and lit up NOTHING, because the
8582
+ // common shape is "one line replaced, plus a comment added above it" — in-order pairing
8583
+ // matched the replaced line against the comment.
8584
+ function saMarkWordSpans(rows){
8585
+ for (let i = 0; i < rows.length; i++) {
8586
+ if (rows[i].t !== 'del') continue;
8587
+ let d = i; while (d < rows.length && rows[d].t === 'del') d++;
8588
+ let a = d; while (a < rows.length && rows[a].t === 'add') a++;
8589
+ const used = new Set();
8590
+ for (let k = i; k < d; k++) {
8591
+ let best = null, bestIdx = -1;
8592
+ for (let j = d; j < a; j++) {
8593
+ if (used.has(j)) continue;
8594
+ const span = saWordSpan(rows[k].c, rows[j].c);
8595
+ if (span && (!best || span.kept > best.kept)) { best = span; bestIdx = j; }
8596
+ }
8597
+ if (!best || best.kept < SA_WORD_KEEP) continue;
8598
+ used.add(bestIdx);
8599
+ rows[k].hi = best.del;
8600
+ rows[bestIdx].hi = best.add;
8601
+ }
8602
+ i = a - 1;
8603
+ }
8604
+ return rows;
8605
+ }
8280
8606
  function saGhDiffHtml(diff, pr){
8281
- const rows = saUnifiedToGh(diff);
8607
+ const rows = saMarkWordSpans(saUnifiedToGh(diff));
8282
8608
  if (!rows.length) return '';
8283
8609
  // Say what was left out. Before this, a 5000-line change rendered its first 80 rows
8284
8610
  // and stopped — a quiet lie, and the one thing SPEC §7 names as forbidden.
@@ -8305,6 +8631,52 @@ function saLogRowHtml(line){
8305
8631
  ? `<div class="txr tool"><span class="w">tool</span><span class="m"><b>${esc(m[1])}</b> ${esc(m[2])}</span></div>`
8306
8632
  : `<div class="txr cl"><span class="w"></span><span class="m">${esc(s)}</span></div>`;
8307
8633
  }
8634
+ // One line of the goal's conversation (ONE-CONVERSATION §1.1). Several entrances —
8635
+ // the composer, a review reply, a thread reply — are one column here, because to the
8636
+ // person who wrote them they were one conversation. `via` says which door a line came
8637
+ // through and nothing else: no per-entrance box, no grouping, no summarising. The text
8638
+ // is whatever was actually said (§ "会話を要約・分類・整形しない").
8639
+ const MSG_WHO = { you: 'You', ai: 'AI', manager: 'Manager' };
8640
+ // `thread` is our word for a reply that came in on the goal's own thread; a person reads
8641
+ // it as 'note'. The label is only shown when the entrance CHANGED from the line above —
8642
+ // five rows do not need to say 'chat' three times.
8643
+ const MSG_VIA = { composer: 'chat', review: 'review', thread: 'note' };
8644
+ function saMsgRowHtml(m, prevVia){
8645
+ const who = MSG_WHO[m?.from] ?? 'AI';
8646
+ const cls = m?.from === 'you' ? 'you' : m?.from === 'manager' ? 'mgr' : 'cl';
8647
+ // Manager rows carry no entrance: `who` already says this line is automatic.
8648
+ const via = m?.from === 'manager' ? '' : (MSG_VIA[m?.via] ?? '');
8649
+ const show = via && via !== prevVia;
8650
+ return `<div class="txr ${cls}"><span class="w">${who}</span><span class="m">${esc(String(m?.text ?? ''))}</span>`
8651
+ + (show ? `<span class="cvia">${via}</span>` : '') + '</div>';
8652
+ }
8653
+ // The rows of a conversation, with each entrance shown only where it changes. A Manager
8654
+ // line never updates `prevVia` — a note in the middle must not make the chat that
8655
+ // continues after it look like a new entrance.
8656
+ //
8657
+ // Long ones fold in the MIDDLE (§B3): the first ask and the latest exchange are what get
8658
+ // read, the middle is what you go back for. From the 9th message: first 2 · +N more ·
8659
+ // last 4. Nothing is summarised and nothing is dropped — pressing it shows everything.
8660
+ // The row right after a fold always prints its entrance, because the change may be
8661
+ // inside what was folded away.
8662
+ const CONV_FOLD_FROM = 9, CONV_HEAD = 2, CONV_TAIL = 4;
8663
+ function saMsgRowsHtml(msgs, expanded){
8664
+ const list = msgs ?? [];
8665
+ const fold = !expanded && list.length >= CONV_FOLD_FROM;
8666
+ const shown = fold ? [...list.slice(0, CONV_HEAD), null, ...list.slice(-CONV_TAIL)] : list;
8667
+ const hidden = list.length - CONV_HEAD - CONV_TAIL;
8668
+ let prevVia = null;
8669
+ return shown.map((m) => {
8670
+ if (m === null) {
8671
+ prevVia = null;
8672
+ return `<div class="txr res convmore"><span class="w"></span><span class="m">+${hidden} more</span></div>`;
8673
+ }
8674
+ const html = saMsgRowHtml(m, prevVia);
8675
+ if (m?.from !== 'manager') prevVia = MSG_VIA[m?.via] ?? prevVia;
8676
+ return html;
8677
+ }).join('');
8678
+ }
8679
+
8308
8680
  // Structured GitHub diff (REVIEW-CARD-SPEC §7): renders {o,n,t,c,h} rows with a
8309
8681
  // word-level highlight (h) and, for a huge change, a "…+N 行 · GitHub で開く ↗"
8310
8682
  // footer (only linking to GitHub when a PR exists). Mirrors the flagship artifact's
@@ -8315,7 +8687,12 @@ function saGhDiffRowsHtml(rows, more, pr){
8315
8687
  const cls = l.t === 'add' ? 'gh-add' : l.t === 'del' ? 'gh-del' : 'gh-ctx';
8316
8688
  const mk = l.t === 'add' ? '+' : l.t === 'del' ? '−' : '';
8317
8689
  let code = esc(l.c);
8318
- if (l.h) { const h = esc(l.h); code = code.replace(h, `<span class="gh-hi">${h}</span>`); }
8690
+ if (Array.isArray(l.hi)) {
8691
+ // Offsets, not a substring search: a word that appears twice on the line must be
8692
+ // marked where it actually changed, not at its first occurrence.
8693
+ const [a, b] = l.hi;
8694
+ code = `${esc(l.c.slice(0, a))}<span class="gh-hi">${esc(l.c.slice(a, b))}</span>${esc(l.c.slice(b))}`;
8695
+ } else if (l.h) { const h = esc(l.h); code = code.replace(h, `<span class="gh-hi">${h}</span>`); }
8319
8696
  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>`;
8320
8697
  }).join('') + '</div>';
8321
8698
  const foot = more ? `<div class="ghfoot"><span>…+${more} 行</span>${pr ? ` · <a class="ghopen" href="${esc(pr)}" target="_blank" rel="noopener">GitHub で開く ↗</a>` : ''}</div>` : '';
@@ -8442,7 +8819,9 @@ function saItemHtml(it, i){
8442
8819
  // address the worker declared IS the control (press it → the process starts on demand →
8443
8820
  // it opens); its own note sits under it, and that note is where the status and any
8444
8821
  // failure reason land after the press. Nothing declared → no section (§1.5-3). ──
8445
- const openBody = it.run
8822
+ // it.run?.compare/title alone (no cmd/url) has nothing a press can start — a
8823
+ // compare-only or title-only declaration must not draw the "Start it" button.
8824
+ const openBody = (it.run?.cmd || it.run?.url)
8446
8825
  ? `<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>`
8447
8826
  : '';
8448
8827
  // ── Also carries (Masa 2026-07-21): goals folded onto this review are logged HERE, inside
@@ -8556,6 +8935,26 @@ function saItemHtml(it, i){
8556
8935
  : it.logTaskId != null
8557
8936
  ? `<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>`
8558
8937
  : '';
8938
+ // ── Conversation (ONE-CONVERSATION §1.1) ─────────────────────────────────────
8939
+ // Everything said about this ticket, in the order it was said, whichever door it
8940
+ // came through. Rendered ABOVE Activity: Activity is what the AI did, and what was
8941
+ // SAID comes before what was done. Fetched on open only — state carries a count,
8942
+ // never the thread (100 cards would drag 100 threads on every tick). A goal nobody
8943
+ // has talked to has no section at all (§1.5-3 存在しないものの器を先に見せない).
8944
+ const msgs = SA.msgs[it.goalId];
8945
+ const msgOpen = SA.detOpen.has(`${gk}:msg`) ? ' open' : '';
8946
+ const msgLast = Array.isArray(msgs) && msgs.length ? msgs[msgs.length - 1] : null;
8947
+ const msgSummary = msgLast
8948
+ ? saMsgRowHtml(msgLast, null)
8949
+ : `<div class="txr"><span class="w"></span><span class="m">${it.messageCount} message${it.messageCount === 1 ? '' : 's'}</span></div>`;
8950
+ const msgBody = Array.isArray(msgs)
8951
+ ? (msgs.length
8952
+ ? saMsgRowsHtml(msgs, SA.convOpen.has(it.goalId))
8953
+ : '<div class="txr res"><span class="w"></span><span class="m">Nothing was said on this one.</span></div>')
8954
+ : '<div class="txr res"><span class="w"></span><span class="m">Loading…</span></div>';
8955
+ const conversationSec = it.messageCount > 0
8956
+ ? `<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>`
8957
+ : '';
8559
8958
  const S = (label, body) => body ? `<section class="rsec"><div class="rsec-h">${label}</div>${body}</section>` : '';
8560
8959
  return `<div class="sa-item lg redesign${it.goalId === SA.cur ? ' cur' : ''}" data-gid="${it.goalId}">
8561
8960
  <div class="rhead"><span class="rtag">${it.no != null ? `#${it.no}` : ''}</span><span class="rst"><span class="rdot"></span>In review</span></div>
@@ -8571,9 +8970,10 @@ function saItemHtml(it, i){
8571
8970
  ${S('Proof', proofInner)}
8572
8971
  ${S('What changed', changedBody)}
8573
8972
  ${riskHtml}
8973
+ ${conversationSec}
8574
8974
  ${activitySec}
8575
8975
  ${S('Workflow', saWorkflowHtml(it))}
8576
- <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>
8976
+ <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>
8577
8977
  </div>`;
8578
8978
  }
8579
8979
  // Scroll the See-all body so a card's top edge (.rhead) sits at the top of the visible
@@ -8603,6 +9003,25 @@ async function saLoadActivityLog(taskId){
8603
9003
  ? lines.map((l) => saLogRowHtml(l)).join('')
8604
9004
  : '<div class="txr res"><span class="w"></span><span class="m">Nothing was logged for this one.</span></div>';
8605
9005
  }
9006
+ // Fetch one goal's conversation and paint it into the open card. Cached in SA.msgs
9007
+ // (an empty result included) so opening and closing a card doesn't ask twice. A failed
9008
+ // request writes an honest line instead of an empty box — the same rule the
9009
+ // implementation log follows.
9010
+ async function saLoadMessages(goalId){
9011
+ if (!Number.isFinite(goalId) || SA.msgs[goalId] !== undefined) return;
9012
+ SA.msgs[goalId] = null; // in flight — a second toggle must not re-fetch
9013
+ let msgs = [];
9014
+ try {
9015
+ const r = await fetch(withKey(`/api/goals/${goalId}/messages`));
9016
+ msgs = r.ok ? ((await r.json()).messages ?? []) : [];
9017
+ } catch { msgs = []; }
9018
+ SA.msgs[goalId] = msgs;
9019
+ const body = document.querySelector(`#seeall [data-msgbody="${goalId}"]`);
9020
+ if (!body) return;
9021
+ body.innerHTML = msgs.length
9022
+ ? saMsgRowsHtml(msgs, SA.convOpen.has(goalId))
9023
+ : '<div class="txr res"><span class="w"></span><span class="m">Nothing was said on this one.</span></div>';
9024
+ }
8606
9025
  // After first paint, compute the red box for any card showing a before/after pair and
8607
9026
  // patch that card's node directly — a full re-render here would close every open
8608
9027
  // pulldown just to draw a box. Cached per goal (null included) so it runs once.
@@ -8630,6 +9049,7 @@ function saRender(){
8630
9049
  // Native <details> would collapse on every rebuild (SSE tick, approve, etc.) —
8631
9050
  // track what's open per goal+section in SA.detOpen (persists across re-renders)
8632
9051
  // and wire each one's toggle to keep that set current.
9052
+ paintReplyNote(); // the answer/question under a card's reply box survives its rebuild
8633
9053
  for (const det of S.querySelectorAll('[data-detkey]')) {
8634
9054
  det.addEventListener('toggle', () => {
8635
9055
  const k = det.dataset.detkey;
@@ -8638,10 +9058,12 @@ function saRender(){
8638
9058
  // "開いた時だけ取りに行く"). Closed cards cost nothing; a reopened one is served
8639
9059
  // from SA.actLog.
8640
9060
  if (det.open && det.dataset.actlog) saLoadActivityLog(Number(det.dataset.actlog));
9061
+ if (det.open && det.dataset.msgs) saLoadMessages(Number(det.dataset.msgs));
8641
9062
  });
8642
9063
  // A card re-rendered while its Activity was already open (SSE tick) keeps the log
8643
9064
  // it had — the fetch below no-ops once SA.actLog holds the answer.
8644
9065
  if (det.open && det.dataset.actlog) saLoadActivityLog(Number(det.dataset.actlog));
9066
+ if (det.open && det.dataset.msgs) saLoadMessages(Number(det.dataset.msgs));
8645
9067
  }
8646
9068
  // "ないものは載せない" (Masa): a Proof <img> whose file is missing/broken must never
8647
9069
  // show a broken-image placeholder. proofImg is only a stored filename, so a file that
@@ -8760,6 +9182,49 @@ async function saCall(ids, path, body){
8760
9182
  }
8761
9183
  return r;
8762
9184
  }
9185
+ // The review card's reply box (§1.3/§1.4). It says "Reply, ask, or send back" —
9186
+ // until now all three did the same thing: sent the goal back to be redone.
9187
+ //
9188
+ // Unlike saAct this does NOT show its outcome first. "Feedback sent — returned to
9189
+ // To Do" is a lie when the reply was a question, and an optimistic lie is worse
9190
+ // than a moment of waiting: the card would say the work was sent back while the
9191
+ // goal sat untouched in review.
9192
+ async function saReplyCall(it, text, outcome){
9193
+ const r = await saCall(it.ids, 'dismiss', { text, outcome });
9194
+ if (!r?.ok) throw new Error('reply failed');
9195
+ return r.json().catch(() => null);
9196
+ }
9197
+ async function saSendReply(it, i, text){
9198
+ const goalId = it.goalId;
9199
+ // By goal, not by row index: the list re-sorts while the AI is thinking, and an
9200
+ // index-based anchor then points at someone else's card (or nothing).
9201
+ const sel = `.sa-chat[data-goal="${goalId}"]`;
9202
+ const seen = state.goals.find((g) => g.id === goalId)?.messageCount ?? 0;
9203
+ it.inflight = true; saRender();
9204
+ showReplySent(sel, text, goalId); // BEFORE the round trip — this is the ~7s that felt broken
9205
+ try {
9206
+ let body = await saReplyCall(it, text, 'auto');
9207
+ if (body?.outcome === 'unsure') {
9208
+ it.inflight = false; saRender();
9209
+ const picked = await askReplyDestination(sel, body.choices);
9210
+ if (!picked) return; // nothing was touched; the words stay in the box
9211
+ it.inflight = true; saRender();
9212
+ body = await saReplyCall(it, text, picked);
9213
+ }
9214
+ it.inflight = false;
9215
+ if (body?.outcome === 'answer') {
9216
+ // The goal never moved, so the card stays exactly where it is — and the
9217
+ // answer appears under the box that asked for it.
9218
+ saRender();
9219
+ showAnswerWhenItArrives(sel, goalId, seen + 1);
9220
+ return;
9221
+ }
9222
+ it.st = 3; saSync(); saAdvanceFocus(goalId);
9223
+ } catch {
9224
+ it.inflight = false; saRender();
9225
+ showErr('Action failed.');
9226
+ }
9227
+ }
8763
9228
  // one action round-trip: optimistic one-liner → endpoint → settle or roll back
8764
9229
  async function saAct(it, st, path, body){
8765
9230
  it.st = st; it.inflight = true; saRender();
@@ -8776,6 +9241,18 @@ async function saAct(it, st, path, body){
8776
9241
  const SA_UNDO = { 1: 'revert-review', 2: 'reopen', 3: 'undismiss', 4: 'unarchive' }; // 07-20c: st 2 = Reject, undo = reopen
8777
9242
  $('seeall').addEventListener('mousedown', (e) => { if (e.target === $('seeall')) saClose(); }); // no-dim dialog: outside = close
8778
9243
  $('seeall').addEventListener('click', async (e) => {
9244
+ // "+N more" in a conversation: show the whole thing, in place. Nothing was summarised
9245
+ // away, so this only has to stop hiding it (§B3).
9246
+ const more = e.target.closest('.convmore');
9247
+ if (more) {
9248
+ const gid = Number(more.closest('[data-msgbody]')?.dataset.msgbody);
9249
+ if (Number.isFinite(gid)) {
9250
+ SA.convOpen.add(gid);
9251
+ const body = document.querySelector(`#seeall [data-msgbody="${gid}"]`);
9252
+ if (body) body.innerHTML = saMsgRowsHtml(SA.msgs[gid], true);
9253
+ }
9254
+ return;
9255
+ }
8779
9256
  const b = e.target.closest('[data-saact]');
8780
9257
  if (!b) return;
8781
9258
  const [k, v] = b.dataset.saact.split(':'); const i = Number(v);
@@ -8826,7 +9303,7 @@ $('seeall').addEventListener('click', async (e) => {
8826
9303
  const inp = $('seeall').querySelector(`.sa-chat[data-i="${i}"]`);
8827
9304
  const text = inp?.value.trim();
8828
9305
  if (!text) { inp?.focus(); return; }
8829
- return saAct(it, 3, 'dismiss', { text }).then((ok) => { if (ok) saAdvanceFocus(it.goalId); });
9306
+ return saSendReply(it, i, text);
8830
9307
  }
8831
9308
  if (k === 'undo') {
8832
9309
  const path = SA_UNDO[it.st];
@@ -8844,7 +9321,7 @@ $('seeall').addEventListener('keydown', (e) => {
8844
9321
  if (!text) return;
8845
9322
  e.preventDefault();
8846
9323
  const it = SA.items[Number(e.target.dataset.i)];
8847
- if (it) saAct(it, 3, 'dismiss', { text }).then((ok) => { if (ok) saAdvanceFocus(it.goalId); }); // no extra confirm (PRD §5.1): Enter = sent back
9324
+ if (it) saSendReply(it, Number(e.target.dataset.i), text); // no extra confirm (PRD §5.1): Enter = sent
8848
9325
  });
8849
9326
  document.addEventListener('keydown', (e) => {
8850
9327
  if (!SA.open || ['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
@@ -8921,6 +9398,115 @@ setAgentEffort(state.connectedApp);
8921
9398
  renderAppSelect();
8922
9399
  renderModelSelect();
8923
9400
  renderEffortSelect();
9401
+ // ---- Context Bar: the folder this project works in --------------------------
9402
+ // A browser cannot open an OS folder dialog that returns a real path, so the choice is
9403
+ // the repos the engine detected (GET /api/repos) plus a typed path — the same two-part
9404
+ // shape the "which folder?" question card has always used. Rendered with the composer's
9405
+ // own dropdown vocabulary (.appmenu/.appopt); no new component.
9406
+ // Mirrors engine/lib.mjs folderName() — the browser cannot import it, and the engine
9407
+ // test pins them to the same answers so the two can't drift apart.
9408
+ function folderName(dir){
9409
+ const d = String(dir ?? '').replace(/\/+$/, '');
9410
+ return d ? d.split('/').pop() : '';
9411
+ }
9412
+ function folderShort(dir){
9413
+ const home = String(state.repoHome || '');
9414
+ const d = String(dir || '');
9415
+ return home && d.startsWith(home) ? `~${d.slice(home.length)}` : d;
9416
+ }
9417
+ function renderCtxBar(){
9418
+ const btn = $('ctxdirnow'); if (!btn) return;
9419
+ const proj = state.projects.find((p) => p.id === state.active);
9420
+ const dir = String(proj?.dir ?? '');
9421
+ // The repo's NAME, the way Claude prints it — the full path lives in the menu and the
9422
+ // tooltip. No folder yet is a real state, not a blank: say so rather than render empty.
9423
+ // folderName is shared with the menu rows below (engine/lib.mjs): two copies of "take
9424
+ // the last segment" is how a chip and its own menu start disagreeing.
9425
+ btn.textContent = dir ? (folderName(dir) || folderShort(dir)) : 'choose a folder';
9426
+ $('ctxdirbtn').title = dir || 'Choose the folder this project works in';
9427
+ // Branch: only when the engine actually read one off the folder (a folder that is not
9428
+ // a repo has none, and an invented branch would be worse than no chip at all).
9429
+ const branch = String(proj?.branch ?? '');
9430
+ const grp = $('ctxbrgrp');
9431
+ if (grp) grp.style.display = branch ? '' : 'none';
9432
+ const bn = $('ctxbranchname'); if (bn) bn.textContent = branch;
9433
+ renderWorktreeChip();
9434
+ const menu = $('ctxdirmenu'); if (!menu) return;
9435
+ const cur = dir;
9436
+ const opts = (state.repos ?? [])
9437
+ .map((r) => `<button type="button" class="appopt${r.dir === cur ? ' active' : ''}" data-ctxdir="${esc(r.dir)}"`
9438
+ + ` title="${esc(r.dir)}"><span class="nm">${esc(folderName(r.dir))}</span>`
9439
+ // LRM, so the rtl clipping trims the HEAD of the path without reversing the text
9440
+ + `<span class="pt">\u200e${esc(folderShort(r.dir))}</span></button>`)
9441
+ .join('');
9442
+ 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>`;
9443
+ }
9444
+ // The worktree checkbox (engine #268). ON — the default, and what every goal has always
9445
+ // done — gives each goal its own copy of the repo, so a run cannot leave this folder
9446
+ // half-edited. OFF sends worktree:false and the work happens right here, which is what
9447
+ // someone means when they want to watch it happen. Sticky per project, like model and
9448
+ // effort, because it is a working habit rather than a per-message thought.
9449
+ function worktreeKey(){ return `sel:worktree:${state.active}`; }
9450
+ function worktreeOn(){ return localStorage.getItem(worktreeKey()) !== '0'; }
9451
+ function renderWorktreeChip(){
9452
+ const el = $('ctxwt'); if (!el) return;
9453
+ el.setAttribute('aria-checked', worktreeOn() ? 'true' : 'false');
9454
+ }
9455
+ // Asked for once, and only when the menu is first opened — the same "only when you look
9456
+ // at it" rule the implementation log and the conversation follow.
9457
+ async function loadRepos(){
9458
+ if (state.repos !== null) return;
9459
+ state.repos = [];
9460
+ try {
9461
+ const r = await fetch(withKey('/api/repos'));
9462
+ const body = r.ok ? await r.json() : {};
9463
+ state.repos = Array.isArray(body.repos) ? body.repos : [];
9464
+ state.repoHome = String(body.home ?? '');
9465
+ } catch { /* no list — the typed path still works, which is the honest fallback */ }
9466
+ renderCtxBar();
9467
+ }
9468
+ // Move this project. The engine refuses a folder where work cannot happen and says
9469
+ // which; that sentence is shown as-is, because "it didn't work" without the reason is
9470
+ // the failure this control exists to prevent.
9471
+ async function setProjectFolder(dir){
9472
+ const want = String(dir ?? '').trim();
9473
+ const msg = $('ctxdirmsg');
9474
+ const say = (t, bad) => { if (msg) { msg.textContent = t; msg.classList.toggle('bad', Boolean(bad)); } };
9475
+ if (!want) { say('Type a path, or pick one above.', true); return; }
9476
+ say('');
9477
+ try {
9478
+ const r = await fetch(withKey(`/api/projects/${encodeURIComponent(state.active)}`), {
9479
+ method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ dir: want }),
9480
+ });
9481
+ const body = await r.json().catch(() => ({}));
9482
+ if (!r.ok) { say(body.error || 'That folder cannot be used.', true); return; }
9483
+ if (Array.isArray(body.projects)) state.projects = body.projects;
9484
+ $('ctxdirmenu').classList.remove('show');
9485
+ render();
9486
+ } catch { say('Could not reach the engine.', true); }
9487
+ }
9488
+ $('ctxdirbtn').onclick = (e) => {
9489
+ e.stopPropagation();
9490
+ const menu = $('ctxdirmenu');
9491
+ const opening = !menu.classList.contains('show');
9492
+ menu.classList.toggle('show');
9493
+ if (opening) { renderCtxBar(); loadRepos(); } // no-op once boot's fetch has landed
9494
+ };
9495
+ $('ctxwt').onclick = (e) => {
9496
+ e.stopPropagation();
9497
+ localStorage.setItem(worktreeKey(), worktreeOn() ? '0' : '1');
9498
+ renderWorktreeChip();
9499
+ };
9500
+ $('ctxdirmenu').addEventListener('click', (e) => {
9501
+ e.stopPropagation();
9502
+ const opt = e.target.closest('[data-ctxdir]');
9503
+ if (opt) { setProjectFolder(opt.dataset.ctxdir); return; }
9504
+ if (e.target.closest('#ctxdiruse')) setProjectFolder($('ctxdirin')?.value ?? '');
9505
+ });
9506
+ $('ctxdirmenu').addEventListener('keydown', (e) => {
9507
+ e.stopPropagation();
9508
+ if (e.key === 'Enter' && e.target.id === 'ctxdirin') { e.preventDefault(); setProjectFolder(e.target.value); }
9509
+ });
8924
9510
  $('appbtn').onclick = (e) => { e.stopPropagation(); $('appmenu').classList.toggle('show'); };
8925
9511
  $('appmenu').addEventListener('click', (e) => {
8926
9512
  const opt = e.target.closest('.appopt');
@@ -8940,6 +9526,10 @@ $('appmenu').addEventListener('click', (e) => {
8940
9526
  });
8941
9527
  document.addEventListener('click', (e) => {
8942
9528
  if (!e.target.closest('.appsel')) $('appmenu').classList.remove('show');
9529
+ // Same rule for the folder menu: a click anywhere else closes it (§: settings-style
9530
+ // popovers close on outside tap — measured against composedPath, not target alone,
9531
+ // so a click inside the menu's own input never closes it).
9532
+ if (!e.composedPath().some((n) => n?.id === 'ctxdirsel')) $('ctxdirmenu').classList.remove('show');
8943
9533
  });
8944
9534
  $('modelbtn').onclick = (e) => { e.stopPropagation(); $('modelmenu').classList.toggle('show'); };
8945
9535
  $('modelmenu').addEventListener('click', (e) => {
@@ -9944,8 +10534,44 @@ function gearSetHTML(){
9944
10534
  <button class="optchip${lang === 'en' ? ' on' : ''}" data-gslang="en">EN</button>
9945
10535
  <button class="optchip${lang === 'ja' ? ' on' : ''}" data-gslang="ja">JA</button></div>
9946
10536
  ${billingRow}
10537
+ ${gearRulesHTML()}
9947
10538
  <div class="srow dis"><div class="sl">Data folder<span class="sd">Runs are stored in the engine's chat-runs folder on the host</span></div></div>`;
9948
10539
  }
10540
+ // ONE-CONVERSATION §1.5a: the project's working rules — the file(s) the connected
10541
+ // AI actually reads (Claude Code → CLAUDE.md, Codex → AGENTS.md). Read-only: Galda
10542
+ // does NOT own these rules, it only surfaces them (editing is done in an editor or
10543
+ // by asking the AI in chat). §3 段階開示 — when the project has no such file the row
10544
+ // is absent entirely (never an empty container; the "ask once" flow is §1.5b).
10545
+ function gearRulesHTML(){
10546
+ const r = state.projectRules[state.active];
10547
+ if (!r || !r.files || !r.files.length) return '';
10548
+ const names = r.files.map((f) => f.name).join(' · ');
10549
+ const view = state.rulesOpen
10550
+ ? `<div class="rulesview">${r.files.map((f) =>
10551
+ `<div class="rulesfile"><div class="rulesname">${esc(f.name)}</div>`
10552
+ + `<pre class="rulestext">${esc(f.content) || '<span class="rulesempty">(empty file)</span>'}</pre></div>`).join('')}</div>`
10553
+ : '';
10554
+ return `
10555
+ <div class="srow"><div class="sl">Project rules<span class="sd">What this project tells the AI — read-only · ${esc(names)}</span></div>
10556
+ <button class="optchip${state.rulesOpen ? ' on' : ''}" data-grules="1">${state.rulesOpen ? 'Hide' : 'View'}</button></div>
10557
+ ${view}`;
10558
+ }
10559
+ // Fetch the active project's rules files once (cached per project), then rebuild
10560
+ // the settings panel so the row appears. The browser can't read the host FS, so
10561
+ // this is the only way in — GET /api/projects/:id/rules (engine, §1.5a).
10562
+ let rulesFetching = null;
10563
+ async function loadProjectRules(){
10564
+ const pid = state.active;
10565
+ if (state.projectRules[pid] || rulesFetching === pid) return;
10566
+ rulesFetching = pid;
10567
+ try {
10568
+ const r = await fetch(withKey(`/api/projects/${pid}/rules`));
10569
+ state.projectRules[pid] = r.ok ? await r.json() : { files: [] };
10570
+ } catch { state.projectRules[pid] = { files: [] }; }
10571
+ finally { rulesFetching = null; }
10572
+ // Only rebuild if the settings tab is still the one on screen.
10573
+ if ($('gearPop') && custTab === 'settings') buildGearPop();
10574
+ }
9949
10575
  // MCP (v46): connection status dot + connectCommand + COPY + quiet explanation.
9950
10576
  // The dot is the app's own live engine link (SSE up/down) — the only real signal here.
9951
10577
  function gearMcpHTML(){
@@ -9996,6 +10622,9 @@ function buildGearPop(){
9996
10622
  try { await fetch(withKey('/api/signout'), { method: 'POST' }); recentlySignedOut = true; }
9997
10623
  finally { await refresh(); if ($('gearPop')) buildGearPop(); }
9998
10624
  };
10625
+ const rulesBtn = pop.querySelector('[data-grules]');
10626
+ if (rulesBtn) rulesBtn.onclick = () => { state.rulesOpen = !state.rulesOpen; buildGearPop(); };
10627
+ loadProjectRules();
9999
10628
  return;
10000
10629
  }
10001
10630
  if (custTab === 'mcp') {
@@ -10129,6 +10758,7 @@ function custPlace(){ // flagship: top-anchored band beside the rail (v45 §2);
10129
10758
  }
10130
10759
  function custOpen(tab){
10131
10760
  custTab = tab || 'theme'; // v46 deep-link (flagship __custOpen(tab)): menu entries land on their tab
10761
+ state.rulesOpen = false; // rules panel starts collapsed each time the gear opens
10132
10762
  custSnap = { cust: { ...cust }, theme: state.theme, band: fsUI.band };
10133
10763
  buildGearPop(); custPlace();
10134
10764
  $('gearPop').classList.add('open');