@galda/cli 0.10.40 → 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}
@@ -2133,6 +2192,23 @@
2133
2192
  /* Manager's own progress notes inside the implementation log: commentary, not results.
2134
2193
  Green would read as "passed" (§5.5 UIクロームは薄く・優しく・主張しない). */
2135
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)}
2136
2212
  /* Discussion (review back-and-forth) folded at the bottom of Activity's body */
2137
2213
  .redesign .lbody .rthread{display:flex;flex-direction:column;gap:16px;margin:0}
2138
2214
  .redesign .lbody .rmsg .who{font-size:12px;font-weight:600;color:var(--ink);margin-bottom:4px}
@@ -2800,6 +2876,26 @@
2800
2876
  <div class="errbar" id="errbar"></div>
2801
2877
  <div class="composer-wrap" id="composerWrap">
2802
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>
2803
2899
  <!-- Conversation-first composer (SLICE 2, docs/HANDOFF-ONBOARDING-conversational.md):
2804
2900
  a chat/help/settings message gets a quiet reply HERE instead of
2805
2901
  becoming a To-Do. Reuses the .peraanswer surface; hidden until a
@@ -3180,7 +3276,7 @@ const savedTheme = (() => {
3180
3276
  if (v !== s) localStorage.setItem('theme', v);
3181
3277
  return v;
3182
3278
  })();
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,
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,
3184
3280
  // per-message send overrides set via slash-commands (reset after each send,
3185
3281
  // except model which is sticky in localStorage). palette = open command list.
3186
3282
  msg: { mode: null, model: null, effort: null, skill: null, later: false, review: false },
@@ -3370,9 +3466,12 @@ function prioBadge(t){
3370
3466
 
3371
3467
  // Mirrors engine/lib.mjs DEFAULT_REVIEW_DEFINITION — kept in sync manually,
3372
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.
3373
3473
  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 },
3474
+ requireVerifyPass: false, description: '', defaultWantsPR: false, language: 'auto',
3376
3475
  reviewCard: { screenshots: true, beforeAfter: false },
3377
3476
  };
3378
3477
  // Shown (faint) under Review when no definition is set yet, so it's obvious the
@@ -4602,9 +4701,14 @@ function renderTasks(){
4602
4701
  // the rework task ("reworking: <goal>").
4603
4702
  const sendQReply = async (gid, inp) => {
4604
4703
  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(); }
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('送信に失敗しました'); }
4608
4712
  };
4609
4713
  for (const b of $('tasklist').querySelectorAll('[data-qreplysend]')) {
4610
4714
  b.onclick = (e) => { e.stopPropagation(); sendQReply(b.dataset.qreplysend, $('tasklist').querySelector(`[data-qreplyinput="${b.dataset.qreplysend}"]`)); };
@@ -4893,13 +4997,12 @@ function renderAttach(){
4893
4997
  function openWfEditor(){
4894
4998
  state.wfDraft = columnsFor(state.active).map((c) => ({ ...c }));
4895
4999
  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
5000
+ // reviewCard is a nested object — a shallow `{ ...def }` would share it by
5001
+ // reference, so toggling one checkbox here would mutate
4898
5002
  // DEFAULT_REVIEW_DEFINITION (or another project's cached definition)
4899
- // instead of just this draft. Copy them one level deeper.
5003
+ // instead of just this draft. Copy it one level deeper.
4900
5004
  state.rdDraft = {
4901
5005
  ...def,
4902
- prSections: { ...DEFAULT_REVIEW_DEFINITION.prSections, ...(def.prSections ?? {}) },
4903
5006
  reviewCard: reviewCardConfigFor(state.active),
4904
5007
  };
4905
5008
  $('wferr').style.display = 'none';
@@ -4912,35 +5015,12 @@ function renderRdEditor(){
4912
5015
  <label class="rdrow"><input type="checkbox" id="rdDefaultWantsPR"${state.rdDraft.defaultWantsPR ? ' checked' : ''}>
4913
5016
  <span><span class="rdlabel">Open a GitHub pull request by default</span>
4914
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>
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
5018
  <label class="rdrow"><input type="checkbox" id="rdRequireVerify"${state.rdDraft.requireVerifyPass ? ' checked' : ''}>
4919
5019
  <span><span class="rdlabel">Requires an independent verify pass</span>
4920
5020
  <span class="rdhint">Any task whose headless-browser check didn't pass (or never ran) forces Review too.</span></span></label>
4921
5021
  <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>
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>
4944
5024
 
4945
5025
  <div class="rdsub">レビューカードに出す項目 / Review card fields</div>
4946
5026
  <label class="rdrow"><input type="checkbox" id="rdCardScreenshots"${state.rdDraft.reviewCard.screenshots ? ' checked' : ''}>
@@ -4956,15 +5036,8 @@ function renderRdEditor(){
4956
5036
  <span><span class="rdlabel">Review language</span>
4957
5037
  <span class="rdhint">レビュー要約(確認/変更)などManagerが生成する文言の言語。</span></span></label>`;
4958
5038
  $('rdDefaultWantsPR').addEventListener('change', (e) => { state.rdDraft.defaultWantsPR = e.target.checked; });
4959
- $('rdRequirePR').addEventListener('change', (e) => { state.rdDraft.requirePR = e.target.checked; });
4960
5039
  $('rdRequireVerify').addEventListener('change', (e) => { state.rdDraft.requireVerifyPass = e.target.checked; });
4961
5040
  $('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
5041
  $('rdCardScreenshots').addEventListener('change', (e) => { state.rdDraft.reviewCard.screenshots = e.target.checked; });
4969
5042
  $('rdLanguage').addEventListener('change', (e) => { state.rdDraft.language = e.target.value; });
4970
5043
  }
@@ -5456,6 +5529,171 @@ function renderReviewAttach(){
5456
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('');
5457
5530
  for (const b of bar.querySelectorAll('.rm')) b.onclick = () => { reviewAttach.splice(Number(b.dataset.ri), 1); renderReviewAttach(); };
5458
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
+ }
5459
5697
  async function sendReviewDismiss(text){
5460
5698
  const goal = state.goals.find((g) => g.id === state.reviewOpenGoal);
5461
5699
  if (!goal) return;
@@ -5466,15 +5704,15 @@ async function sendReviewDismiss(text){
5466
5704
  ? `${text}${text ? '\n\n' : ''}添付画像(workerはこのパスをReadで確認できます): ${imgs.map((a) => a.path).join(', ')}`
5467
5705
  : text;
5468
5706
  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();
5707
+ const body = await replyToGoal(goal.id, fullText, '#reviewFixInput');
5708
+ if (!body) return; // unsure, and nobody chose
5473
5709
  upsert(state.goals, body.goal);
5474
5710
  if (body.task) upsert(state.tasks, body.task);
5475
5711
  $('reviewFixInput').value = '';
5476
5712
  reviewAttach = []; renderReviewAttach();
5477
- 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();
5478
5716
  render();
5479
5717
  } catch { showErr('Dismiss failed.'); }
5480
5718
  }
@@ -5696,14 +5934,12 @@ async function sendReject(goalId){
5696
5934
  async function sendDismiss(goalId, text){
5697
5935
  $('gdApprove').disabled = true; $('gdDismiss').disabled = true; $('gdSend').disabled = true;
5698
5936
  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();
5937
+ const body = await replyToGoal(goalId, text, '#gdRevisionInput');
5938
+ if (!body) { $('gdApprove').disabled = false; $('gdDismiss').disabled = false; $('gdSend').disabled = false; return; }
5703
5939
  upsert(state.goals, body.goal);
5704
5940
  upsert(state.tasks, body.task);
5705
5941
  $('gdRevisionInput').value = '';
5706
- closeGoalDetail();
5942
+ if (body.outcome !== 'answer') closeGoalDetail(); // a question leaves the card open for its answer
5707
5943
  render();
5708
5944
  } catch { showErr('Dismiss failed.'); }
5709
5945
  $('gdApprove').disabled = false; $('gdDismiss').disabled = false; $('gdSend').disabled = false;
@@ -5759,11 +5995,13 @@ $('gdRevisionInput').addEventListener('keydown', (e) => {
5759
5995
 
5760
5996
  function render(){
5761
5997
  renderProjects(); renderStream(); renderQueue(); renderTasks(); renderActPanel(); wireWfEditButtons();
5998
+ renderCtxBar(); // the folder above the composer follows the active project
5762
5999
  renderFlagship();
5763
6000
  if (state.reviewOpenGoal != null) renderReviewChecklist();
5764
6001
  if (state.goalDetailOpen != null) renderGoalDetail();
5765
6002
  if (typeof SA !== 'undefined' && SA.open) saSync(); // keep the open See all Ledger live
5766
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
5767
6005
  }
5768
6006
 
5769
6007
  // ============================================================================
@@ -6093,8 +6331,9 @@ function fsBoardBucket(goal, tasks){
6093
6331
  if (s === 'needsInput') return 'needs'; // a clarifying question is waiting on the user
6094
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へ移動しない)
6095
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)
6096
- 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)
6097
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.
6098
6337
  if (s === 'stacked' || s === 'sending') return 'todo';
6099
6338
  if (s === 'running' && !goal?.startedAt) return 'todo'; // goalChip: planned, nothing started yet
6100
6339
  // Otherwise the goal is in flight (planning / retesting / running-and-started). BUT a
@@ -6217,8 +6456,8 @@ function renderFsBoard(){
6217
6456
  return `<div class="lbc${quiet ? ' is-quiet' : ''}">${h}</div>`;
6218
6457
  };
6219
6458
  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);
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
6222
6461
  for (const k of Object.keys(bucket)) bucket[k].sort((a, b) => k === 'done' ? no(b) - no(a) : no(a) - no(b));
6223
6462
  // To Do / Doing / Needs you / Review / Done / Later — the original design's columns
6224
6463
  // (Masa 2026-07-16). "Needs you" = a clarifying question is waiting on the user —
@@ -6403,6 +6642,10 @@ function fsBucketTodo(){
6403
6642
  const list = state.tasks.filter((t) => t.projectId === state.active);
6404
6643
  const order = state.queueOrder[state.active] ?? [];
6405
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));
6406
6649
  return {
6407
6650
  pgoals, list,
6408
6651
  running: list.filter((t) => t.status === 'running'),
@@ -6411,7 +6654,7 @@ function fsBucketTodo(){
6411
6654
  needsInput: pgoals.filter((g) => g.status === 'needsInput'), // a clarifying question is waiting on the user (board's "Needs you")
6412
6655
  reviewGoals: pgoals.filter((g) => g.status === 'review'),
6413
6656
  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),
6657
+ doneAll: list.filter((t) => ['done', 'skipped'].includes(t.status) && !closedGoalIds.has(t.goalId)).sort((a, b) => b.num - a.num),
6415
6658
  later: pgoals.filter((g) => g.status === 'pending'),
6416
6659
  rejected: pgoals.filter((g) => g.status === 'rejected'), // 07-20c: Reject parks goals here (own fold, like Done)
6417
6660
  };
@@ -7730,6 +7973,12 @@ async function boot(){
7730
7973
  if (window.__GALDA_DISCONNECTED) return bootDisconnected(window.__GALDA_DISCONNECTED);
7731
7974
  $('input').value = localStorage.getItem('draft') ?? '';
7732
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();
7733
7982
  flushOutbox();
7734
7983
  connectSSE();
7735
7984
  startHealthPoll();
@@ -7770,7 +8019,7 @@ async function postOutboxItem(item){
7770
8019
  posting.add(item.oid);
7771
8020
  try {
7772
8021
  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 }) });
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 }) });
7774
8023
  if (r.status === 402) {
7775
8024
  const data = await r.json().catch(() => ({}));
7776
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
@@ -7942,6 +8191,9 @@ async function send(){
7942
8191
  oid: `o${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
7943
8192
  projectId: state.active, text, pr: 'auto', model, effort, agent: state.connectedApp,
7944
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,
7945
8197
  pending: later, review,
7946
8198
  images: attach.map((a) => a.path), imageUrls: attach.map((a) => a.url),
7947
8199
  status: 'sending', ts: Date.now(),
@@ -8103,7 +8355,7 @@ function closeSummaryPera(){ $('summaryOverlay').classList.remove('show'); }
8103
8355
  // 4 archived / 5 reverted — session-local; the server round-trip is what
8104
8356
  // settles it (see saSync's "returned to review" reset).
8105
8357
  // ============================================================================
8106
- const SA = { open: false, items: [], diffs: {}, actLog: {}, shotBox: {}, 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 };
8107
8359
  window.SA = SA; // top-level const isn't a window property; external checks probe window.SA (same convention as window.state above)
8108
8360
  const SA_SVG = {
8109
8361
  ok: '<svg viewBox="0 0 24 24"><path d="M4 12l5 5L20 6"/></svg>',
@@ -8169,6 +8421,11 @@ function saRows(){
8169
8421
  const ids = [...r.goalIds, ...folded.map((f) => f.id)];
8170
8422
  return ts.filter((t) => ids.includes(t.goalId)).flatMap((t) => t.shots ?? []);
8171
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,
8172
8429
  // The task whose implementation log the engine froze at completion (P1.5). The
8173
8430
  // lines are NOT in state — 1000 of them per task would bloat every page load — so
8174
8431
  // this is just the id to ask for when the reviewer opens Activity.
@@ -8243,10 +8500,18 @@ function saRows(){
8243
8500
  // has called it since the review-card redesign; Request and Result carry that ground now.)
8244
8501
  // Unified-diff → GitHub two-gutter model (REVIEW-CARD-SPEC §7 "GitHub diff = Bright"):
8245
8502
  // 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.
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).
8250
8515
  // How many body rows the diff really has, ignoring the display cap. A big change used
8251
8516
  // to be cut at 80 rows with nothing said (SPEC §7 asks for "…+N 行 · GitHub で開く ↗"
8252
8517
  // precisely so a reviewer is never shown a slice that looks like the whole thing).
@@ -8277,8 +8542,61 @@ function saUnifiedToGh(diff, max){
8277
8542
  }
8278
8543
  return out;
8279
8544
  }
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
+ }
8280
8598
  function saGhDiffHtml(diff, pr){
8281
- const rows = saUnifiedToGh(diff);
8599
+ const rows = saMarkWordSpans(saUnifiedToGh(diff));
8282
8600
  if (!rows.length) return '';
8283
8601
  // Say what was left out. Before this, a 5000-line change rendered its first 80 rows
8284
8602
  // and stopped — a quiet lie, and the one thing SPEC §7 names as forbidden.
@@ -8305,6 +8623,52 @@ function saLogRowHtml(line){
8305
8623
  ? `<div class="txr tool"><span class="w">tool</span><span class="m"><b>${esc(m[1])}</b> ${esc(m[2])}</span></div>`
8306
8624
  : `<div class="txr cl"><span class="w"></span><span class="m">${esc(s)}</span></div>`;
8307
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
+
8308
8672
  // Structured GitHub diff (REVIEW-CARD-SPEC §7): renders {o,n,t,c,h} rows with a
8309
8673
  // word-level highlight (h) and, for a huge change, a "…+N 行 · GitHub で開く ↗"
8310
8674
  // footer (only linking to GitHub when a PR exists). Mirrors the flagship artifact's
@@ -8315,7 +8679,12 @@ function saGhDiffRowsHtml(rows, more, pr){
8315
8679
  const cls = l.t === 'add' ? 'gh-add' : l.t === 'del' ? 'gh-del' : 'gh-ctx';
8316
8680
  const mk = l.t === 'add' ? '+' : l.t === 'del' ? '−' : '';
8317
8681
  let code = esc(l.c);
8318
- 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>`); }
8319
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>`;
8320
8689
  }).join('') + '</div>';
8321
8690
  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 +8811,9 @@ function saItemHtml(it, i){
8442
8811
  // address the worker declared IS the control (press it → the process starts on demand →
8443
8812
  // it opens); its own note sits under it, and that note is where the status and any
8444
8813
  // failure reason land after the press. Nothing declared → no section (§1.5-3). ──
8445
- 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)
8446
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>`
8447
8818
  : '';
8448
8819
  // ── Also carries (Masa 2026-07-21): goals folded onto this review are logged HERE, inside
@@ -8556,6 +8927,26 @@ function saItemHtml(it, i){
8556
8927
  : it.logTaskId != null
8557
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>`
8558
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>`
8949
+ : '';
8559
8950
  const S = (label, body) => body ? `<section class="rsec"><div class="rsec-h">${label}</div>${body}</section>` : '';
8560
8951
  return `<div class="sa-item lg redesign${it.goalId === SA.cur ? ' cur' : ''}" data-gid="${it.goalId}">
8561
8952
  <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 +8962,10 @@ function saItemHtml(it, i){
8571
8962
  ${S('Proof', proofInner)}
8572
8963
  ${S('What changed', changedBody)}
8573
8964
  ${riskHtml}
8965
+ ${conversationSec}
8574
8966
  ${activitySec}
8575
8967
  ${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>
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>
8577
8969
  </div>`;
8578
8970
  }
8579
8971
  // Scroll the See-all body so a card's top edge (.rhead) sits at the top of the visible
@@ -8603,6 +8995,25 @@ async function saLoadActivityLog(taskId){
8603
8995
  ? lines.map((l) => saLogRowHtml(l)).join('')
8604
8996
  : '<div class="txr res"><span class="w"></span><span class="m">Nothing was logged for this one.</span></div>';
8605
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
+ }
8606
9017
  // After first paint, compute the red box for any card showing a before/after pair and
8607
9018
  // patch that card's node directly — a full re-render here would close every open
8608
9019
  // pulldown just to draw a box. Cached per goal (null included) so it runs once.
@@ -8630,6 +9041,7 @@ function saRender(){
8630
9041
  // Native <details> would collapse on every rebuild (SSE tick, approve, etc.) —
8631
9042
  // track what's open per goal+section in SA.detOpen (persists across re-renders)
8632
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
8633
9045
  for (const det of S.querySelectorAll('[data-detkey]')) {
8634
9046
  det.addEventListener('toggle', () => {
8635
9047
  const k = det.dataset.detkey;
@@ -8638,10 +9050,12 @@ function saRender(){
8638
9050
  // "開いた時だけ取りに行く"). Closed cards cost nothing; a reopened one is served
8639
9051
  // from SA.actLog.
8640
9052
  if (det.open && det.dataset.actlog) saLoadActivityLog(Number(det.dataset.actlog));
9053
+ if (det.open && det.dataset.msgs) saLoadMessages(Number(det.dataset.msgs));
8641
9054
  });
8642
9055
  // A card re-rendered while its Activity was already open (SSE tick) keeps the log
8643
9056
  // it had — the fetch below no-ops once SA.actLog holds the answer.
8644
9057
  if (det.open && det.dataset.actlog) saLoadActivityLog(Number(det.dataset.actlog));
9058
+ if (det.open && det.dataset.msgs) saLoadMessages(Number(det.dataset.msgs));
8645
9059
  }
8646
9060
  // "ないものは載せない" (Masa): a Proof <img> whose file is missing/broken must never
8647
9061
  // show a broken-image placeholder. proofImg is only a stored filename, so a file that
@@ -8760,6 +9174,49 @@ async function saCall(ids, path, body){
8760
9174
  }
8761
9175
  return r;
8762
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
+ }
8763
9220
  // one action round-trip: optimistic one-liner → endpoint → settle or roll back
8764
9221
  async function saAct(it, st, path, body){
8765
9222
  it.st = st; it.inflight = true; saRender();
@@ -8776,6 +9233,18 @@ async function saAct(it, st, path, body){
8776
9233
  const SA_UNDO = { 1: 'revert-review', 2: 'reopen', 3: 'undismiss', 4: 'unarchive' }; // 07-20c: st 2 = Reject, undo = reopen
8777
9234
  $('seeall').addEventListener('mousedown', (e) => { if (e.target === $('seeall')) saClose(); }); // no-dim dialog: outside = close
8778
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
+ }
8779
9248
  const b = e.target.closest('[data-saact]');
8780
9249
  if (!b) return;
8781
9250
  const [k, v] = b.dataset.saact.split(':'); const i = Number(v);
@@ -8826,7 +9295,7 @@ $('seeall').addEventListener('click', async (e) => {
8826
9295
  const inp = $('seeall').querySelector(`.sa-chat[data-i="${i}"]`);
8827
9296
  const text = inp?.value.trim();
8828
9297
  if (!text) { inp?.focus(); return; }
8829
- return saAct(it, 3, 'dismiss', { text }).then((ok) => { if (ok) saAdvanceFocus(it.goalId); });
9298
+ return saSendReply(it, i, text);
8830
9299
  }
8831
9300
  if (k === 'undo') {
8832
9301
  const path = SA_UNDO[it.st];
@@ -8844,7 +9313,7 @@ $('seeall').addEventListener('keydown', (e) => {
8844
9313
  if (!text) return;
8845
9314
  e.preventDefault();
8846
9315
  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
9316
+ if (it) saSendReply(it, Number(e.target.dataset.i), text); // no extra confirm (PRD §5.1): Enter = sent
8848
9317
  });
8849
9318
  document.addEventListener('keydown', (e) => {
8850
9319
  if (!SA.open || ['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
@@ -8921,6 +9390,115 @@ setAgentEffort(state.connectedApp);
8921
9390
  renderAppSelect();
8922
9391
  renderModelSelect();
8923
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
+ });
8924
9502
  $('appbtn').onclick = (e) => { e.stopPropagation(); $('appmenu').classList.toggle('show'); };
8925
9503
  $('appmenu').addEventListener('click', (e) => {
8926
9504
  const opt = e.target.closest('.appopt');
@@ -8940,6 +9518,10 @@ $('appmenu').addEventListener('click', (e) => {
8940
9518
  });
8941
9519
  document.addEventListener('click', (e) => {
8942
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');
8943
9525
  });
8944
9526
  $('modelbtn').onclick = (e) => { e.stopPropagation(); $('modelmenu').classList.toggle('show'); };
8945
9527
  $('modelmenu').addEventListener('click', (e) => {