@galda/cli 0.10.72 → 0.10.74

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
@@ -213,7 +213,7 @@
213
213
 
214
214
  .errbar{position:absolute;left:50%;bottom:96px;transform:translateX(-50%);z-index:40;background:var(--errbg);backdrop-filter:blur(14px);
215
215
  border:1px solid rgba(var(--danger-rgb),.4);color:var(--danger);font-size:12.5px;border-radius:10px;padding:8px 14px;display:none}
216
- .errbar.ok{border-color:rgba(var(--green-rgb),.45);color:var(--green)} /* 07-20c: neutral/success toast (PR merged) */
216
+ .errbar.ok{background:rgba(var(--green-rgb),.12);border-color:rgba(var(--green-rgb),.45);color:var(--green)} /* 07-20c: neutral/success toast (PR merged) */
217
217
 
218
218
  /* composer */
219
219
  .composer-wrap{flex:0 0 auto;padding:8px 22px 18px}
@@ -296,21 +296,6 @@
296
296
  .ctxseg:hover{color:var(--ink)} /* glyph colour only — no surface (§5.5) */
297
297
  .ctxseg svg{width:13px;height:13px;flex:0 0 13px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
298
298
  .ctxseg b{font-weight:600;font-size:11.5px;color:var(--ink);overflow:hidden;text-overflow:ellipsis;max-width:300px}
299
- .ctxdiv{width:1px;align-self:stretch;margin:5px 0;background:var(--hair);flex:0 0 1px}
300
- /* workspace mode — isolated worktree is the default; direct workspace is explicit.
301
- ON is --ink, never --green: in this product green means "it passed" (PR green,
302
- tests green, Token green), and a settings checkbox borrowing it makes that green cheap.
303
- The tick is knocked out in --panel (the surface it sits on), NOT --invink. */
304
- .ctxwt{font-size:11.5px;color:var(--ink2)}
305
- .ctxwt .ctxbox{width:11px;height:11px;flex:0 0 11px;border-radius:3px;border:1px solid var(--ink3);
306
- display:inline-block;position:relative;transition:background .12s,border-color .12s}
307
- .ctxwt[aria-checked="true"]{color:var(--ink2)}
308
- .ctxwt[aria-checked="true"] .ctxbox{background:var(--ink);border-color:var(--ink)}
309
- .ctxwt[aria-checked="true"] .ctxbox::after{content:"";position:absolute;left:3px;top:0;width:3px;height:7px;
310
- border:solid var(--panel);border-width:0 1.6px 1.6px 0;transform:rotate(42deg)}
311
- .ctxworkwarn{display:flex;align-items:center;gap:6px;margin:-3px 2px 6px;color:var(--amber);
312
- font:500 11.5px/1.35 var(--ui)}
313
- .ctxworkwarn[hidden]{display:none}
314
299
  .ctxmenu{min-width:230px;max-width:420px}
315
300
  /* The folder list is galda's own screen (Claude has none — it opens ONE folder; galda
316
301
  moves between repos). The one thing that actually goes wrong is that names alone look
@@ -591,7 +576,8 @@
591
576
  .riskfiles{font-family:var(--mono);font-size:10px;color:var(--ink3)}
592
577
  /* task 161/162: Review-row quick actions — aligned to the right of the row,
593
578
  vertically centered; icon (no emoji) reply; subtle inline reply box. */
594
- .revquick{display:flex;gap:4px;flex:0 0 auto;align-items:center;align-self:center;margin-left:8px}
579
+ .revquick{display:flex;gap:2px;flex:0 0 auto;align-items:center;align-self:center;margin-left:8px;opacity:0;transition:opacity .15s}
580
+ .task:hover .revquick,.digestrow:hover .revquick,.revquick:focus-within{opacity:1}
595
581
  .qbtn{width:26px;height:26px;flex:0 0 26px;border:1px solid var(--hair2);border-radius:7px;background:transparent;cursor:pointer;font-size:12px;line-height:0;display:grid;place-items:center;color:var(--ink2);padding:0}
596
582
  .qbtn svg{width:14px;height:14px}
597
583
  .qbtn.ok{color:var(--green);border-color:rgba(var(--green-rgb),.4)}
@@ -599,6 +585,15 @@
599
585
  .qbtn.no{color:var(--danger);border-color:rgba(var(--danger-rgb),.4)}
600
586
  .qbtn.no:hover{background:rgba(var(--danger-rgb),.12)}
601
587
  .qbtn:hover{color:var(--ink)}
588
+ /* Review行だけ: flagshipの .act/.b パターン(枠なし・ホバーで出現・面を塗らずグリフ色のみ) */
589
+ .revquick .qbtn{border:0;border-radius:6px}
590
+ .revquick .qbtn.ok{color:var(--ink3);border-color:transparent;background:transparent}
591
+ .revquick .qbtn.ok:hover{color:var(--green);background:transparent}
592
+ .revquick .qbtn.no{color:var(--ink3);border-color:transparent;background:transparent}
593
+ .revquick .qbtn.no:hover{color:var(--danger);background:transparent}
594
+ .revquick .qbtn.reply{color:var(--ink3)}
595
+ .revquick .qbtn.reply:hover{color:var(--ink)}
596
+ .revquick .qbtn:active{transform:scale(.92)}
602
597
  .qreplyrow{display:flex;gap:6px;margin-top:8px;width:100%}
603
598
  .qreplyrow[hidden]{display:none}
604
599
  .qreplyrow input{flex:1;min-width:0;height:30px;border:1px solid var(--hair);border-radius:8px;background:transparent;color:var(--ink);padding:0 10px;font:inherit;font-size:12px}
@@ -1507,15 +1502,6 @@
1507
1502
  background:var(--ink);animation:ccblink 1.05s steps(1) infinite}
1508
1503
  @keyframes ccblink{50%{opacity:0}}
1509
1504
  @media (prefers-reduced-motion:reduce){#fsRoot .cc-caret{animation:none}}
1510
- /* "→ Added to To Do #NN" — shown quietly in the flow when a turn became a task.
1511
- Undo appears on hover only (§1.5-10). No filled surface. */
1512
- #fsRoot .cc-mark{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:11px;color:var(--ink3);padding:1px 0}
1513
- #fsRoot .cc-mark .cm-ar{color:var(--ink2);font-size:12.5px;line-height:1}
1514
- #fsRoot .cc-mark .cm-t{color:var(--ink2)}
1515
- #fsRoot .cc-mark .cm-no{color:var(--ink3);font-variant-numeric:tabular-nums}
1516
- #fsRoot .cc-mark .cm-undo{margin-left:2px;padding:0;border:0;background:none;font:inherit;color:var(--ink3);cursor:pointer;opacity:0;transition:opacity .15s,color .15s}
1517
- #fsRoot .cc-mark:hover .cm-undo{opacity:1}
1518
- #fsRoot .cc-mark .cm-undo:hover{color:var(--blue)}
1519
1505
  /* Claude Code CLI language (HANDOFF-v45 §12, flagship .cc-t port): gap:0 — the bullet's
1520
1506
  own margin-right does the spacing, not a flex gap (keeps ⏺ tight against the tool name
1521
1507
  the way the real CLI reads). */
@@ -2134,6 +2120,10 @@
2134
2120
  /* body prose = the app's normal text: 14px / −.012em, matching .bubble (Masa 2026-07-20:
2135
2121
  13px read thinner/lighter for Japanese than the surrounding app). §2. */
2136
2122
  .redesign .rbody{font-size:14px;line-height:1.62;letter-spacing:-.012em;color:var(--ink);max-width:70ch;margin:0 !important}
2123
+ /* Request text is the human's own free-form message (goal #431): it can contain blank
2124
+ lines and numbered lists. esc() already neutralizes HTML, so pre-wrap just keeps the
2125
+ line breaks the person actually typed instead of HTML collapsing them into one line. */
2126
+ .redesign .rbody.rask{white-space:pre-wrap}
2137
2127
  .redesign .rinfra{padding:10px 12px;border:1px solid rgba(var(--amber-rgb),.35);border-radius:8px;background:rgba(var(--amber-rgb),.06)}
2138
2128
  .redesign .rinfra .rbody{color:var(--ink2)}
2139
2129
  .redesign .rinfra .retrybtn{margin-top:8px;margin-left:0}
@@ -2982,13 +2972,8 @@
2982
2972
  <div class="ctxchip ctxbrgrp" id="ctxbrgrp">
2983
2973
  <span class="ctxseg ctxbranch" id="ctxbranch" title="Branch this folder is on">
2984
2974
  <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>
2985
- <span class="ctxdiv"></span>
2986
- <button type="button" class="ctxseg ctxwt" id="ctxwt" role="checkbox" aria-checked="true"
2987
- title="isolated worktree: default clean copy. direct workspace: inherits and edits this folder.">
2988
- <i class="ctxbox"></i><span id="ctxwtlabel">isolated worktree</span></button>
2989
2975
  </div>
2990
2976
  </div>
2991
- <div class="ctxworkwarn" id="ctxworkwarn" hidden></div>
2992
2977
  <!-- Conversation-first composer (SLICE 2, docs/HANDOFF-ONBOARDING-conversational.md):
2993
2978
  a chat/help/settings message gets a quiet reply HERE instead of
2994
2979
  becoming a To-Do. Reuses the .peraanswer surface; hidden until a
@@ -3375,8 +3360,20 @@ const savedTheme = (() => {
3375
3360
  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: localStorage.getItem('sel:project') || 'default', editing: null, attach: [], connectCommand: '', models: ['sonnet'], agentModels: null, agentEfforts: null, agents: ['claude-code', 'codex'], auth: null, expanded: new Set(), openThreads: new Set(), queueOrder: {}, externalActivity: [], selectedGoal: null, workflowColumns: {}, wfDraft: null, reviewDefinitions: {}, rdDraft: null, runningCounts: {}, parallelLimits: 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,
3376
3361
  // per-message send overrides set via slash-commands (reset after each send,
3377
3362
  // except model which is sticky in localStorage). palette = open command list.
3363
+ // Scoped per project (msgByProject) so a skill/mode picked in one project can't leak
3364
+ // into another one just switched to — state.msg is always the CURRENT project's slot;
3365
+ // switching projects must go through setActiveProject(), never a bare state.active = .
3378
3366
  msg: { mode: null, model: null, effort: null, skill: null, later: false, review: false },
3367
+ msgByProject: {},
3379
3368
  stickyModel: localStorage.getItem('sel:model') || 'sonnet', stickyEffort: localStorage.getItem('sel:effort') || 'medium', palette: null };
3369
+ state.msgByProject[state.active] = state.msg;
3370
+ function emptyMsg(){ return { mode: null, model: null, effort: null, skill: null, later: false, review: false }; }
3371
+ function setActiveProject(id){
3372
+ if (id === state.active) return;
3373
+ state.msgByProject[state.active] = state.msg;
3374
+ state.active = id;
3375
+ state.msg = state.msgByProject[id] || (state.msgByProject[id] = emptyMsg());
3376
+ }
3380
3377
  window.state = state; // top-level const isn't a window property; external checks probe window.state
3381
3378
  const $ = (id) => document.getElementById(id);
3382
3379
 
@@ -3431,6 +3428,8 @@ const FINISHED = (s) => ['done', 'partial', 'failed', 'interrupted'].includes(s)
3431
3428
  const ICONS = {
3432
3429
  pencil: '<svg viewBox="0 0 24 24"><path d="M4 20l1-4L16.5 4.5a2.1 2.1 0 013 3L8 19l-4 1z"/></svg>',
3433
3430
  reply: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 17l-5-5 5-5"/><path d="M4 12h11a4 4 0 0 1 4 4v3"/></svg>',
3431
+ check: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.2 4.2L19 7"/></svg>',
3432
+ close: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l12 12M18 6L6 18"/></svg>',
3434
3433
  up: '<svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg>',
3435
3434
  down: '<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12l7 7 7-7"/></svg>',
3436
3435
  trash: '<svg viewBox="0 0 24 24"><path d="M4 7h16M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2M6.5 7l1 13h9l1-13M10 11v6M14 11v6"/></svg>',
@@ -3901,10 +3900,13 @@ function tokenBaselinePerRequirementLocal(history){
3901
3900
  return samples.reduce((a, b) => a + b, 0) / samples.length;
3902
3901
  }
3903
3902
  // Mirrors engine/lib.mjs estimateTokenOptimization() — see the comment there
3904
- // for why "saved X%" now compares against a real per-project baseline
3905
- // (basis: 'baseline') instead of always reusing this goal's own cache-hit
3906
- // ratio as a stand-in for "cheaper than usual" (basis: 'cache-weight',
3907
- // used only until there's enough finished-goal history).
3903
+ // for why a positive `baseline` percent is only labeled "saved" (basis:
3904
+ // 'baseline') when a real technique fired this run (cache reuse, a fresh
3905
+ // handoff, /compact, /clear). Without one, the same comparison ships as
3906
+ // basis: 'baseline-untied' a true fact (smaller/larger than usual) but
3907
+ // not a claim that anything the user did caused it (Masa 2026-07-26; a
3908
+ // small goal right after big ones scored a huge "saved" purely from the
3909
+ // size mix). With no history at all it's basis: 'no-baseline' (no number).
3908
3910
  function estimateTokenOptimizationLocal({ usage = null, weightedTokens = null, cacheReadWeight = 0.1, sessionMode = null, usedHandoff = false, usedCompact = false, usedClear = false, agentModel = null, requirementCount = 1, history = [] } = {}){
3909
3911
  const u = usage ?? {};
3910
3912
  const raw = (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0);
@@ -3913,15 +3915,11 @@ function estimateTokenOptimizationLocal({ usage = null, weightedTokens = null, c
3913
3915
  const reqN = Math.max(1, Number(requirementCount) || 1);
3914
3916
  const perReq = weighted / reqN;
3915
3917
  const baseline = tokenBaselinePerRequirementLocal(history);
3916
- let percent, basis, savedTokens;
3918
+ let percent = null, basis = 'no-baseline', savedTokens = null;
3917
3919
  if (baseline != null) {
3918
3920
  percent = Math.max(-100, Math.min(100, Math.round(((baseline - perReq) / baseline) * 100)));
3919
3921
  basis = 'baseline';
3920
3922
  savedTokens = Math.max(0, Math.round((baseline - perReq) * reqN));
3921
- } else {
3922
- savedTokens = Math.max(0, raw - weighted);
3923
- percent = Math.round((savedTokens / raw) * 100);
3924
- basis = 'cache-weight';
3925
3923
  }
3926
3924
  const techniques = [];
3927
3925
  const cacheRatio = Math.round(((u.cache_read_input_tokens ?? 0) / raw) * 100);
@@ -3929,8 +3927,10 @@ function estimateTokenOptimizationLocal({ usage = null, weightedTokens = null, c
3929
3927
  if (usedHandoff || sessionMode === 'cold-handoff') techniques.push('fresh handoff instead of a bloated session');
3930
3928
  if (usedCompact || sessionMode === 'compact') techniques.push('/compact before continuing');
3931
3929
  if (usedClear) techniques.push('/clear between unrelated goals');
3932
- if (agentModel) techniques.push(`${agentModel} for this run`);
3930
+ const techniqueDriven = cacheRatio > 0 || usedHandoff || sessionMode === 'cold-handoff' || usedCompact || sessionMode === 'compact' || usedClear;
3931
+ if (basis === 'baseline' && !techniqueDriven) basis = 'baseline-untied';
3933
3932
  if (basis === 'baseline' && percent > 0) techniques.push(`${Math.round(perReq / 1000)}k tokens/requirement vs your ${Math.round(baseline / 1000)}k average`);
3933
+ if (basis === 'baseline-untied') techniques.push(`${Math.round(perReq / 1000)}k tokens/requirement vs your ${Math.round(baseline / 1000)}k average`);
3934
3934
  if (!techniques.length) techniques.push(`${Math.round(raw / 1000)}k tokens this run (no cache reuse yet)`);
3935
3935
  return { rawTokens: raw, weightedTokens: weighted, savedTokens, percent, basis, techniques: [...new Set(techniques)] };
3936
3936
  }
@@ -3938,6 +3938,10 @@ function tokenOptimizationHtml(opt){
3938
3938
  if (!opt) return '';
3939
3939
  const techniques = opt.techniques?.length ? opt.techniques.slice(0, 4).join(' / ') : 'this run';
3940
3940
  const pct = Number(opt.percent);
3941
+ if (opt.basis === 'baseline-untied') {
3942
+ const dir = pct > 0 ? 'smaller' : 'larger';
3943
+ return `<div class="rvtokenopt">${esc(`Token usage: this run was ${dir} than your recent average (${techniques}) — no cache/compact/handoff technique behind it`)}</div>`;
3944
+ }
3941
3945
  const cmp = opt.basis === 'baseline' ? 'vs your recent average' : 'from prompt caching';
3942
3946
  const text = pct > 0
3943
3947
  ? `Token optimized: saved about ${pct}% ${cmp} (${techniques})`
@@ -3949,7 +3953,7 @@ function renderProjects(){
3949
3953
  const busy = state.tasks.filter((t) => t.projectId === p.id && ['running','queued'].includes(t.status)).length;
3950
3954
  return `<button class="proj ${p.id === state.active ? 'active' : ''}" data-id="${p.id}">${esc(p.name)}${busy ? `<span class="n">${busy}</span>` : ''}</button>`;
3951
3955
  }).join('');
3952
- for (const b of document.querySelectorAll('.proj')) b.onclick = () => { state.active = b.dataset.id; render(); };
3956
+ for (const b of document.querySelectorAll('.proj')) b.onclick = () => { setActiveProject(b.dataset.id); render(); };
3953
3957
  }
3954
3958
 
3955
3959
  function proofMediaHtml(taskId, proof, altLabel){
@@ -4105,8 +4109,8 @@ function reviewDigestHtml(rows){
4105
4109
  <div class="digestmeta">${testBadge}${prLink}</div>
4106
4110
  </div>
4107
4111
  <div class="revquick">
4108
- <button type="button" class="qbtn ok" data-qapprove="${ids}" title="Approve">✓</button>
4109
- <button type="button" class="qbtn no" data-qdismiss="${ids}" title="Send back">✕</button>
4112
+ <button type="button" class="qbtn ok" data-qapprove="${ids}" title="Approve">${ICONS.check}</button>
4113
+ <button type="button" class="qbtn no" data-qdismiss="${ids}" title="Send back">${ICONS.close}</button>
4110
4114
  </div>
4111
4115
  </div>`;
4112
4116
  };
@@ -4372,8 +4376,9 @@ function renderStream(){
4372
4376
  inp.addEventListener('keydown', async (e) => {
4373
4377
  if (e.key !== 'Enter' || e.isComposing || !inp.value.trim()) return;
4374
4378
  const text = inp.value.trim(); inp.value = ''; inp.disabled = true;
4375
- const r = await fetch(withKey(`/api/goals/${inp.dataset.goal}/reply`), { method: 'POST',
4376
- headers: { 'content-type': 'application/json' }, body: JSON.stringify(replyWorkerPayload({ text })) });
4379
+ let r; try { r = await fetch(withKey(`/api/goals/${inp.dataset.goal}/reply`), { method: 'POST',
4380
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify(replyWorkerPayload({ text })) }); }
4381
+ catch { showErr('Reply failed — the goal may not have started yet.'); inp.disabled = false; return; }
4377
4382
  if (!r.ok) showErr('Reply failed — the goal may not have started yet.');
4378
4383
  inp.disabled = false;
4379
4384
  });
@@ -4498,18 +4503,22 @@ function renderQueue(){
4498
4503
  const text = document.getElementById(`qedit-${id}`).value.trim();
4499
4504
  const pr = document.getElementById(`qedit-pr-${id}`).dataset.val;
4500
4505
  if (text) {
4501
- const r = await fetch(withKey(`/api/goals/${id}`), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, pr }) });
4502
- if (r.ok) upsert(state.goals, await r.json());
4506
+ try {
4507
+ const r = await fetch(withKey(`/api/goals/${id}`), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, pr }) });
4508
+ if (r.ok) upsert(state.goals, await r.json()); else showErr('Save failed.');
4509
+ } catch { showErr('Save failed.'); }
4503
4510
  }
4504
4511
  state.editing = null; render();
4505
4512
  };
4506
4513
  for (const b of q.querySelectorAll('.qp')) b.onclick = async () => {
4507
- const r = await fetch(withKey(`/api/goals/${b.dataset.id}`), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ promote: true }) });
4514
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.id}`), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ promote: true }) }); }
4515
+ catch { showErr('Too late — this goal already started.'); render(); return; }
4508
4516
  if (r.ok) upsert(state.goals, await r.json()); else showErr('Too late — this goal already started.');
4509
4517
  render();
4510
4518
  };
4511
4519
  for (const b of q.querySelectorAll('.qd')) b.onclick = async () => {
4512
- const r = await fetch(withKey(`/api/goals/${b.dataset.id}`), { method: 'DELETE' });
4520
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.id}`), { method: 'DELETE' }); }
4521
+ catch { showErr('Too late — this goal already started.'); await refresh(); render(); return; }
4513
4522
  if (r.ok) state.goals = state.goals.filter((g) => g.id !== Number(b.dataset.id));
4514
4523
  else { showErr('Too late — this goal already started.'); await refresh(); }
4515
4524
  render();
@@ -4689,8 +4698,8 @@ function renderTasks(){
4689
4698
  <div class="cond">${g.pr ? `<a class="prbig" href="${esc(g.pr)}" target="_blank">PR #${esc((g.pr.match(/pull\/(\d+)/) ?? [,''])[1])} ↗</a>` : ''}</div>
4690
4699
  <div class="qreplyrow" data-qreplyrow="${g.id}"${state.qreplyOpen.has(g.id) ? '' : ' hidden'}><input type="text" data-qreplyinput="${g.id}" placeholder="直したい点を書いて送信(Todoに戻して再作業)"><button type="button" class="qreplysend" data-qreplysend="${g.id}" aria-label="Send">${ICONS.up}</button></div></div>
4691
4700
  <div class="revquick">
4692
- <button type="button" class="qbtn ok" data-qapprove="${g.id}" title="Approve">✓</button>
4693
- <button type="button" class="qbtn no" data-qdismiss="${g.id}" title="Send back">✕</button>
4701
+ <button type="button" class="qbtn ok" data-qapprove="${g.id}" title="Approve">${ICONS.check}</button>
4702
+ <button type="button" class="qbtn no" data-qdismiss="${g.id}" title="Send back">${ICONS.close}</button>
4694
4703
  <button type="button" class="qbtn reply" data-qreply="${g.id}" title="Reply">${ICONS.reply}</button>
4695
4704
  </div></div>`;
4696
4705
  }).join('')
@@ -4724,15 +4733,19 @@ function renderTasks(){
4724
4733
  const id = Number(el.dataset.task);
4725
4734
  const rb = e.target.closest('[data-retrybtn]');
4726
4735
  if (rb) {
4727
- const r = await fetch(withKey(`/api/tasks/${rb.dataset.retrybtn}/retry`), { method: 'POST' });
4728
- if (!r.ok) showErr('Retry failed.'); else await refresh();
4736
+ try {
4737
+ const r = await fetch(withKey(`/api/tasks/${rb.dataset.retrybtn}/retry`), { method: 'POST' });
4738
+ if (!r.ok) showErr('Retry failed.'); else await refresh();
4739
+ } catch { showErr('Retry failed.'); }
4729
4740
  return;
4730
4741
  }
4731
4742
  const sb = e.target.closest('[data-skipbtn]');
4732
4743
  if (sb) {
4733
- const r = await fetch(withKey(`/api/tasks/${sb.dataset.skipbtn}/skip`), { method: 'POST',
4734
- headers: { 'content-type': 'application/json' }, body: JSON.stringify({ reason: 'Dismissed from Attention.' }) });
4735
- if (!r.ok) showErr('Dismiss failed.'); else await refresh();
4744
+ try {
4745
+ const r = await fetch(withKey(`/api/tasks/${sb.dataset.skipbtn}/skip`), { method: 'POST',
4746
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ reason: 'Dismissed from Attention.' }) });
4747
+ if (!r.ok) showErr('Dismiss failed.'); else await refresh();
4748
+ } catch { showErr('Dismiss failed.'); }
4736
4749
  return;
4737
4750
  }
4738
4751
  // Attention rows open the same detail dialog as Review (tap anywhere but
@@ -4776,11 +4789,13 @@ function renderTasks(){
4776
4789
  b.onclick = async (e) => { e.stopPropagation();
4777
4790
  const bx = b.getBoundingClientRect(); // capture before await/re-render — burst FROM the ✓ button
4778
4791
  let ok = true, mergeGoal = null;
4779
- for (const id of b.dataset.qapprove.split(',')) {
4780
- const r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
4781
- if (!r.ok){ ok = false; break; }
4782
- const j = await r.json(); if (j && j.needsMerge) mergeGoal = j.goal; // 07-20c: PR goal
4783
- }
4792
+ try {
4793
+ for (const id of b.dataset.qapprove.split(',')) {
4794
+ const r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
4795
+ if (!r.ok){ ok = false; break; }
4796
+ const j = await r.json(); if (j && j.needsMerge) mergeGoal = j.goal; // 07-20c: PR goal
4797
+ }
4798
+ } catch { ok = false; }
4784
4799
  if (!ok){ showErr('Approve failed.'); return; }
4785
4800
  await refresh();
4786
4801
  if (mergeGoal){ confirmMerge(mergeGoal); return; } // merge instead of confetti
@@ -4790,19 +4805,24 @@ function renderTasks(){
4790
4805
  // 07-20c: the inline ✕ is a pure Reject — park the goal in the Rejected column (/reject),
4791
4806
  // no auto-rework. Rework is asked for via chat.
4792
4807
  b.onclick = async (e) => { e.stopPropagation();
4793
- let r; for (const id of b.dataset.qdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/reject`), { method: 'POST' });
4808
+ let r; try { for (const id of b.dataset.qdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/reject`), { method: 'POST' }); }
4809
+ catch { showErr('Dismiss failed.'); return; }
4794
4810
  qrefresh(r, 'Dismiss failed.'); };
4795
4811
  }
4796
4812
  // Archive: retire an unfinished goal cleanly (no rework spawned). The escape
4797
4813
  // hatch for half-finished (partial/blocked) items so Attention stays drainable.
4798
4814
  for (const b of $('tasklist').querySelectorAll('[data-archivegoal]')) {
4799
4815
  b.onclick = async (e) => { e.stopPropagation();
4800
- qrefresh(await fetch(withKey(`/api/goals/${b.dataset.archivegoal}/archive`), { method: 'POST' }), 'Archive failed.'); };
4816
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.archivegoal}/archive`), { method: 'POST' }); }
4817
+ catch { showErr('Archive failed.'); return; }
4818
+ qrefresh(r, 'Archive failed.'); };
4801
4819
  }
4802
4820
  // Manager-verified gate: re-run tests + re-capture proof for a blocked goal.
4803
4821
  for (const b of $('tasklist').querySelectorAll('[data-goalreverify]')) {
4804
4822
  b.onclick = async (e) => { e.stopPropagation(); b.disabled = true; b.textContent = '再検証中…';
4805
- qrefresh(await fetch(withKey(`/api/goals/${b.dataset.goalreverify}/reverify`), { method: 'POST' }), '再検証に失敗'); };
4823
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.goalreverify}/reverify`), { method: 'POST' }); }
4824
+ catch { showErr('再検証に失敗'); return; }
4825
+ qrefresh(r, '再検証に失敗'); };
4806
4826
  }
4807
4827
  for (const b of $('tasklist').querySelectorAll('[data-qreply]')) {
4808
4828
  b.onclick = (e) => { e.stopPropagation();
@@ -4845,14 +4865,16 @@ function renderTasks(){
4845
4865
  for (const b of $('tasklist').querySelectorAll('[data-pstart]')) {
4846
4866
  b.onclick = async (e) => {
4847
4867
  e.stopPropagation();
4848
- const r = await fetch(withKey(`/api/goals/${b.dataset.pstart}/activate`), { method: 'POST' });
4868
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.pstart}/activate`), { method: 'POST' }); }
4869
+ catch { showErr('Start failed.'); return; }
4849
4870
  if (!r.ok) showErr('Start failed.'); else await refresh();
4850
4871
  };
4851
4872
  }
4852
4873
  for (const b of $('tasklist').querySelectorAll('[data-pdel]')) {
4853
4874
  b.onclick = async (e) => {
4854
4875
  e.stopPropagation();
4855
- const r = await fetch(withKey(`/api/goals/${b.dataset.pdel}`), { method: 'DELETE' });
4876
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.pdel}`), { method: 'DELETE' }); }
4877
+ catch { showErr('Delete failed.'); return; }
4856
4878
  if (!r.ok) showErr('Delete failed.'); else await refresh();
4857
4879
  };
4858
4880
  }
@@ -4864,13 +4886,15 @@ function renderTasks(){
4864
4886
  e.stopPropagation();
4865
4887
  const preview = b.closest('.goalhead')?.querySelector('.gt')?.textContent || '';
4866
4888
  if (!(await confirmDelete({ title: 'Delete this goal?', preview }))) return;
4867
- const r = await fetch(withKey(`/api/goals/${b.dataset.ghdel}`), { method: 'DELETE' });
4889
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.ghdel}`), { method: 'DELETE' }); }
4890
+ catch { showErr('Cannot delete a running/finished goal — reply to append instead'); return; }
4868
4891
  if (!r.ok) showErr('Cannot delete a running/finished goal — reply to append instead'); else await refresh();
4869
4892
  };
4870
4893
  // Smart intake: answer a planner clarifying question (chip or free text) → re-plan.
4871
4894
  const answerQ = async (id, answer) => {
4872
4895
  if (!answer) return;
4873
- const r = await fetch(withKey(`/api/goals/${id}/answer`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ answer }) });
4896
+ let r; try { r = await fetch(withKey(`/api/goals/${id}/answer`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ answer }) }); }
4897
+ catch { showErr('Failed to send answer'); return; }
4874
4898
  if (!r.ok) showErr('Failed to send answer'); else await refresh();
4875
4899
  };
4876
4900
  for (const b of $('tasklist').querySelectorAll('[data-qanswer]')) b.onclick = (e) => { e.stopPropagation(); answerQ(Number(b.dataset.qanswer), b.dataset.answer); };
@@ -4967,7 +4991,8 @@ function openGoalheadReply(id, btn){
4967
4991
  const snd = document.createElement('button'); snd.className = 'qbtn ok'; snd.textContent = '↑';
4968
4992
  wrap.appendChild(inp); wrap.appendChild(snd); head.insertAdjacentElement('afterend', wrap); inp.focus();
4969
4993
  const send = async () => { const t = inp.value.trim(); if (!t) return;
4970
- const r = await fetch(withKey(`/api/goals/${id}/reply`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(replyWorkerPayload({ text: t })) });
4994
+ let r; try { r = await fetch(withKey(`/api/goals/${id}/reply`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(replyWorkerPayload({ text: t })) }); }
4995
+ catch { showErr('未開始のゴールは編集を使ってください'); return; }
4971
4996
  if (!r.ok) showErr('未開始のゴールは編集を使ってください'); await refresh(); };
4972
4997
  inp.onkeydown = (e) => { if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); send(); } if (e.key === 'Escape') wrap.remove(); };
4973
4998
  snd.onclick = send;
@@ -4990,9 +5015,11 @@ function wirePriority(){
4990
5015
  b.onclick = async (e) => {
4991
5016
  e.stopPropagation();
4992
5017
  b.closest('.priomenu')?.classList.remove('show');
4993
- const r = await fetch(withKey(`/api/tasks/${b.dataset.priotask}/priority`), { method: 'PUT',
4994
- headers: { 'content-type': 'application/json' }, body: JSON.stringify({ priority: b.dataset.val }) });
4995
- if (!r.ok) showErr('優先度の変更に失敗しました。'); else await refresh();
5018
+ try {
5019
+ const r = await fetch(withKey(`/api/tasks/${b.dataset.priotask}/priority`), { method: 'PUT',
5020
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ priority: b.dataset.val }) });
5021
+ if (!r.ok) showErr('優先度の変更に失敗しました。'); else await refresh();
5022
+ } catch { showErr('優先度の変更に失敗しました。'); }
4996
5023
  };
4997
5024
  }
4998
5025
  }
@@ -5188,26 +5215,31 @@ $('wfHeaderEdit').onclick = (e) => { e.stopPropagation(); openWfEditor(); };
5188
5215
  $('wfoverlay').addEventListener('click', (e) => { if (e.target === $('wfoverlay')) $('wfoverlay').classList.remove('show'); });
5189
5216
  $('wfsave').onclick = async () => {
5190
5217
  const columns = state.wfDraft.map((c) => ({ key: c.key, label: c.label.trim(), bucket: c.bucket }));
5191
- const r = await fetch(withKey(`/api/projects/${state.active}/columns`), { method: 'PUT',
5192
- headers: { 'content-type': 'application/json' }, body: JSON.stringify({ columns }) });
5193
- const data = await r.json().catch(() => ({}));
5194
- if (!r.ok) {
5195
- $('wferr').textContent = data.error || 'Save failed.';
5196
- $('wferr').style.display = 'block';
5197
- return;
5198
- }
5199
- const rdr = await fetch(withKey(`/api/projects/${state.active}/review-definition`), { method: 'PUT',
5200
- headers: { 'content-type': 'application/json' }, body: JSON.stringify({ reviewDefinition: state.rdDraft }) });
5201
- const rdData = await rdr.json().catch(() => ({}));
5202
- if (!rdr.ok) {
5203
- $('wferr').textContent = rdData.error || 'Save failed.';
5218
+ try {
5219
+ const r = await fetch(withKey(`/api/projects/${state.active}/columns`), { method: 'PUT',
5220
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ columns }) });
5221
+ const data = await r.json().catch(() => ({}));
5222
+ if (!r.ok) {
5223
+ $('wferr').textContent = data.error || 'Save failed.';
5224
+ $('wferr').style.display = 'block';
5225
+ return;
5226
+ }
5227
+ const rdr = await fetch(withKey(`/api/projects/${state.active}/review-definition`), { method: 'PUT',
5228
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ reviewDefinition: state.rdDraft }) });
5229
+ const rdData = await rdr.json().catch(() => ({}));
5230
+ if (!rdr.ok) {
5231
+ $('wferr').textContent = rdData.error || 'Save failed.';
5232
+ $('wferr').style.display = 'block';
5233
+ return;
5234
+ }
5235
+ state.workflowColumns[state.active] = data.columns;
5236
+ state.reviewDefinitions[state.active] = rdData.reviewDefinition;
5237
+ $('wfoverlay').classList.remove('show');
5238
+ render();
5239
+ } catch {
5240
+ $('wferr').textContent = 'Save failed.';
5204
5241
  $('wferr').style.display = 'block';
5205
- return;
5206
5242
  }
5207
- state.workflowColumns[state.active] = data.columns;
5208
- state.reviewDefinitions[state.active] = rdData.reviewDefinition;
5209
- $('wfoverlay').classList.remove('show');
5210
- render();
5211
5243
  };
5212
5244
 
5213
5245
  function wireWfEditButtons(){
@@ -5432,7 +5464,8 @@ function renderReviewChecklist(){
5432
5464
  e.stopPropagation();
5433
5465
  b.disabled = true;
5434
5466
  b.textContent = '再検証中…';
5435
- const r = await fetch(withKey(`/api/goals/${b.dataset.goalreverify}/reverify`), { method: 'POST' });
5467
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.goalreverify}/reverify`), { method: 'POST' }); }
5468
+ catch { showErr('再検証に失敗'); b.disabled = false; return; }
5436
5469
  if (r.ok) await refresh(); else showErr('再検証に失敗');
5437
5470
  };
5438
5471
  }
@@ -5444,9 +5477,10 @@ function renderReviewChecklist(){
5444
5477
  if (!text) return;
5445
5478
  const t = reqs.find((r) => r.id === taskId);
5446
5479
  b.disabled = true; input.disabled = true;
5447
- const r = await fetch(withKey(`/api/goals/${goalId}/reply`), { method: 'POST',
5480
+ let r; try { r = await fetch(withKey(`/api/goals/${goalId}/reply`), { method: 'POST',
5448
5481
  headers: { 'content-type': 'application/json' },
5449
- body: JSON.stringify(replyWorkerPayload({ text: `[Fix request for requirement #${t?.num ?? ''} "${t?.title ?? ''}"] ${text}` })) });
5482
+ body: JSON.stringify(replyWorkerPayload({ text: `[Fix request for requirement #${t?.num ?? ''} "${t?.title ?? ''}"] ${text}` })) }); }
5483
+ catch { showErr('Failed to send the fix request.'); b.disabled = false; input.disabled = false; return; }
5450
5484
  if (r.ok) { input.value = ''; input.placeholder = 'Sent — the worker will pick it up'; }
5451
5485
  else showErr('Failed to send the fix request.');
5452
5486
  b.disabled = false; input.disabled = false;
@@ -6367,7 +6401,7 @@ function renderFsRail(){
6367
6401
  el.innerHTML = html;
6368
6402
  for (const b of el.querySelectorAll('[data-fsproj]')) b.onclick = (e) => {
6369
6403
  if (e.target.closest('.pname.editing') || b.querySelector('.pname.editing')) return;
6370
- state.active = b.dataset.fsproj; document.body.classList.remove('projectsopen'); render();
6404
+ setActiveProject(b.dataset.fsproj); document.body.classList.remove('projectsopen'); render();
6371
6405
  };
6372
6406
  const add = $('fsProjAdd'); if (add) add.onclick = createProject;
6373
6407
  }
@@ -6538,11 +6572,6 @@ function renderFsFeed(){
6538
6572
  let t = '';
6539
6573
  if (turn.role === 'you') {
6540
6574
  t += `<div class="cc-you"><span class="cy-n">You</span><div class="cy-t">${esc(turn.text)}</div></div>`;
6541
- if (turn.markGoalId != null && !turn.undone) {
6542
- const mg = state.goals.find((x) => x.id === turn.markGoalId);
6543
- const n = mg ? goalReviewNumber({ goal: mg, tasks: state.tasks }) : turn.markGoalId;
6544
- t += `<div class="cc-mark"><span class="cm-ar">→</span><span class="cm-t">Added to To Do</span><span class="cm-no">#${n}</span><button class="cm-undo" type="button" data-ccundo="${turn.markGoalId}">Undo</button></div>`;
6545
- }
6546
6575
  } else { // Claude Code reply — speaker line first (You → speaker → reply), then the text
6547
6576
  const app = connectedAppMeta(turn.agent);
6548
6577
  t += `<div class="cc-who">${app.icon}<span class="cs-n">${esc(app.label)}</span></div>`;
@@ -6595,24 +6624,12 @@ function renderFsFeed(){
6595
6624
  // Delete a "needs your answer" card outright (restores 「Needs your answer」カードを削除
6596
6625
  // できるように — a question you don't want to answer had no discard path in flagship).
6597
6626
  for (const b of feed.querySelectorAll('[data-fsanswerdel]')) b.onclick = (e) => { e.stopPropagation(); fsTaskDelete(Number(b.dataset.fsanswerdel)); };
6598
- // Pattern A (H23): Undo a "→ Added to To Do" marker — cancel the created goal
6599
- // and drop the mark. A just-created (planning) goal is deletable; if it already
6600
- // started the server returns non-OK (canDeleteGoal) and we say so quietly.
6601
- for (const b of feed.querySelectorAll('[data-ccundo]')) b.onclick = async (e) => {
6602
- e.stopPropagation();
6603
- const gid = Number(b.dataset.ccundo);
6604
- const r = await fetch(withKey(`/api/goals/${gid}`), { method: 'DELETE' });
6605
- if (!r.ok) { showErr('Could not undo — the task may already be running'); return; }
6606
- const turn = (state.chatTurns[state.active] || []).find((t) => t.markGoalId === gid);
6607
- if (turn) turn.undone = true;
6608
- state.goals = state.goals.filter((g) => g.id !== gid);
6609
- render();
6610
- };
6611
6627
  }
6612
6628
  async function fsAnswerQuestion(goalId, answer){
6613
6629
  if (!answer) return;
6614
- const r = await fetch(withKey(`/api/goals/${goalId}/answer`), { method: 'POST',
6615
- headers: { 'content-type': 'application/json' }, body: JSON.stringify({ answer }) });
6630
+ let r; try { r = await fetch(withKey(`/api/goals/${goalId}/answer`), { method: 'POST',
6631
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ answer }) }); }
6632
+ catch { showErr('Failed to send answer'); return; }
6616
6633
  if (!r.ok) showErr('Failed to send answer'); else await refresh();
6617
6634
  }
6618
6635
  async function fsRetryTask(id){
@@ -6989,17 +7006,20 @@ function renderFsReview(){
6989
7006
  // "Also carries" section (saItemHtml + the seeall handler).
6990
7007
  for (const b of el.querySelectorAll('[data-fsapprove]')) b.onclick = async (e) => { e.stopPropagation();
6991
7008
  let ok = true, mergeGoal = null;
6992
- for (const id of b.dataset.fsapprove.split(',')) {
6993
- const r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
6994
- if (!r.ok){ ok = false; break; }
6995
- const j = await r.json(); if (j && j.needsMerge) mergeGoal = j.goal; // 07-20c: PR goal
6996
- }
7009
+ try {
7010
+ for (const id of b.dataset.fsapprove.split(',')) {
7011
+ const r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
7012
+ if (!r.ok){ ok = false; break; }
7013
+ const j = await r.json(); if (j && j.needsMerge) mergeGoal = j.goal; // 07-20c: PR goal
7014
+ }
7015
+ } catch { ok = false; }
6997
7016
  if (!ok){ showErr('Approve failed.'); return; }
6998
7017
  await refresh();
6999
7018
  if (mergeGoal) confirmMerge(mergeGoal); else celebrateApprove(); };
7000
7019
  for (const b of el.querySelectorAll('[data-fsdismiss]')) b.onclick = async (e) => { e.stopPropagation();
7001
7020
  // 07-20c: the review-card ✕ is a pure Reject → Rejected column (/reject), no auto-rework.
7002
- let r; for (const id of b.dataset.fsdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/reject`), { method: 'POST' });
7021
+ let r; try { for (const id of b.dataset.fsdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/reject`), { method: 'POST' }); }
7022
+ catch { showErr('Dismiss failed.'); return; }
7003
7023
  if (r && r.ok) await refresh(); else showErr('Dismiss failed.'); };
7004
7024
  }
7005
7025
  // Shared To Do bucketing (list rows AND the kanban board read the same groups —
@@ -7290,14 +7310,16 @@ function renderFsTodo(){
7290
7310
  if (Number.isFinite(id)) fsOpenGoalDetail(id);
7291
7311
  };
7292
7312
  for (const b of el.querySelectorAll('[data-fsreopen]')) b.onclick = async (e) => { e.stopPropagation();
7293
- const r = await fetch(withKey(`/api/goals/${b.dataset.fsreopen}/reopen`), { method: 'POST' });
7313
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsreopen}/reopen`), { method: 'POST' }); }
7314
+ catch { showErr('Reopen failed.'); return; }
7294
7315
  if (!r.ok) showErr('Reopen failed.'); else await refresh(); };
7295
7316
  for (const b of el.querySelectorAll('[data-fsrejdel]')) b.onclick = async (e) => { e.stopPropagation();
7296
7317
  const preview = b.closest('.trow')?.querySelector('.tt')?.textContent || '';
7297
7318
  // Delete on a rejected goal = revert: it closes the PR + drops the worktree, so a rejected
7298
7319
  // goal that had a PR doesn't leave an orphan PR behind (DELETE would 409 — rejected isn't deletable).
7299
7320
  if (!(await confirmDelete({ title: 'Discard this rejected goal?', preview }))) return;
7300
- const r = await fetch(withKey(`/api/goals/${b.dataset.fsrejdel}/revert`), { method: 'POST' });
7321
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsrejdel}/revert`), { method: 'POST' }); }
7322
+ catch { showErr('Delete failed.'); return; }
7301
7323
  if (!r.ok) showErr('Delete failed.'); else await refresh(); };
7302
7324
  const more = el.querySelector('#fsTodoMore');
7303
7325
  if (more) more.onclick = () => { fsUI.todoMore = !fsUI.todoMore; renderFsTodo(); };
@@ -7316,45 +7338,53 @@ function renderFsTodo(){
7316
7338
  for (const b of el.querySelectorAll('[data-fsglog]')) b.onclick = () => toggleGoalDetail(Number(b.dataset.fsglog));
7317
7339
  for (const b of el.querySelectorAll('[data-fsreverify]')) b.onclick = async () => {
7318
7340
  b.disabled = true; b.textContent = 'Starting…';
7319
- const r = await fetch(withKey(`/api/goals/${b.dataset.fsreverify}/reverify`), { method: 'POST' });
7341
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsreverify}/reverify`), { method: 'POST' }); }
7342
+ catch { showErr(document.documentElement.lang === 'ja' ? 'Retryに失敗しました' : 'Retry failed.'); b.disabled = false; b.textContent = 'Retry'; return; }
7320
7343
  if (!r.ok) { showErr(document.documentElement.lang === 'ja' ? 'Retryに失敗しました' : 'Retry failed.'); b.disabled = false; b.textContent = 'Retry'; }
7321
7344
  else await refresh();
7322
7345
  };
7323
7346
  for (const b of el.querySelectorAll('[data-fsarchive]')) b.onclick = async () => {
7324
- const r = await fetch(withKey(`/api/goals/${b.dataset.fsarchive}/archive`), { method: 'POST' });
7347
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsarchive}/archive`), { method: 'POST' }); }
7348
+ catch { showErr('Archive failed.'); return; }
7325
7349
  if (!r.ok) showErr('Archive failed.'); else await refresh();
7326
7350
  };
7327
7351
  // LATER row actions (PRD §8.2): Start queues the shelved goal, Delete discards it.
7328
7352
  for (const b of el.querySelectorAll('[data-fspstart]')) b.onclick = async (e) => { e.stopPropagation();
7329
- const r = await fetch(withKey(`/api/goals/${b.dataset.fspstart}/activate`), { method: 'POST' });
7353
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fspstart}/activate`), { method: 'POST' }); }
7354
+ catch { showErr('Start failed.'); return; }
7330
7355
  if (!r.ok) showErr('Start failed.'); else await refresh(); };
7331
7356
  const startNextLater = el.querySelector('[data-fsstartnextlater]');
7332
7357
  if (startNextLater) startNextLater.onclick = async (e) => { e.stopPropagation();
7333
7358
  startNextLater.disabled = true;
7334
- const r = await fetch(withKey(`/api/goals/${startNextLater.dataset.fsstartnextlater}/activate`), { method: 'POST' });
7359
+ let r; try { r = await fetch(withKey(`/api/goals/${startNextLater.dataset.fsstartnextlater}/activate`), { method: 'POST' }); }
7360
+ catch { showErr(document.documentElement.lang === 'ja' ? '開始できませんでした' : 'Could not start the next item.'); startNextLater.disabled = false; return; }
7335
7361
  if (!r.ok) { showErr(document.documentElement.lang === 'ja' ? '開始できませんでした' : 'Could not start the next item.'); startNextLater.disabled = false; }
7336
7362
  else await refresh(); };
7337
7363
  for (const b of el.querySelectorAll('[data-fspdel]')) b.onclick = async (e) => { e.stopPropagation();
7338
7364
  const preview = b.closest('.trow')?.querySelector('.tt')?.textContent || '';
7339
7365
  if (!(await confirmDelete({ title: 'Delete this shelved goal?', preview }))) return;
7340
- const r = await fetch(withKey(`/api/goals/${b.dataset.fspdel}`), { method: 'DELETE' });
7366
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fspdel}`), { method: 'DELETE' }); }
7367
+ catch { showErr('Delete failed.'); return; }
7341
7368
  if (!r.ok) showErr('Delete failed.'); else await refresh(); };
7342
7369
  // hover-toolbar actions → same endpoints as the SL1 goalhead toolbar
7343
7370
  for (const b of el.querySelectorAll('[data-fsreply]')) b.onclick = (e) => { e.stopPropagation();
7344
7371
  fsInlineInput(b.closest('.trow'), '', 'Reply to this goal…', async (text) => {
7345
- const r = await fetch(withKey(`/api/goals/${b.dataset.fsreply}/reply`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(replyWorkerPayload({ text })) });
7372
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsreply}/reply`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(replyWorkerPayload({ text })) }); }
7373
+ catch { showErr('未開始のゴールは編集を使ってください'); return; }
7346
7374
  if (!r.ok) showErr('未開始のゴールは編集を使ってください'); await refresh();
7347
7375
  }); };
7348
7376
  for (const b of el.querySelectorAll('[data-fsedit]')) b.onclick = (e) => { e.stopPropagation();
7349
7377
  const g = state.goals.find((x) => x.id === Number(b.dataset.fsedit));
7350
7378
  fsInlineInput(b.closest('.trow'), g?.text ?? '', 'Edit the goal…', async (text) => {
7351
- const r = await fetch(withKey(`/api/goals/${b.dataset.fsedit}`), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }) });
7379
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsedit}`), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }) }); }
7380
+ catch { showErr('Cannot edit a running/finished goal — reply to append instead'); return; }
7352
7381
  if (!r.ok) showErr('Cannot edit a running/finished goal — reply to append instead'); await refresh();
7353
7382
  }); };
7354
7383
  for (const b of el.querySelectorAll('[data-fsdel]')) b.onclick = async (e) => { e.stopPropagation();
7355
7384
  const preview = b.closest('.trow')?.querySelector('.tt')?.textContent || '';
7356
7385
  if (!(await confirmDelete({ title: 'Delete this goal?', preview }))) return;
7357
- const r = await fetch(withKey(`/api/goals/${b.dataset.fsdel}`), { method: 'DELETE' });
7386
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsdel}`), { method: 'DELETE' }); }
7387
+ catch { showErr('Cannot delete a running/finished goal — reply to append instead'); return; }
7358
7388
  if (!r.ok) showErr('Cannot delete a running/finished goal — reply to append instead'); else await refresh(); };
7359
7389
  }
7360
7390
  async function fsCreateGoal(text, pending){
@@ -7472,7 +7502,7 @@ async function createProject(){
7472
7502
  if (!r.ok) throw new Error();
7473
7503
  const body = await r.json();
7474
7504
  state.projects = body.projects ?? [...state.projects, body.project].filter(Boolean);
7475
- state.active = body.project.id;
7505
+ setActiveProject(body.project.id);
7476
7506
  document.body.classList.remove('projectsopen');
7477
7507
  render();
7478
7508
  } catch { showErr('New project failed.'); }
@@ -7504,7 +7534,7 @@ async function deleteProject(p){
7504
7534
  const body = await r.json().catch(() => ({}));
7505
7535
  if (!r.ok) throw new Error(body.error || 'Delete project failed.');
7506
7536
  state.projects = body.projects ?? state.projects.filter((x) => x.id !== p.id);
7507
- if (state.active === p.id) state.active = state.projects[0]?.id ?? state.active;
7537
+ if (state.active === p.id) setActiveProject(state.projects[0]?.id ?? state.active);
7508
7538
  document.body.classList.remove('projectsopen');
7509
7539
  render();
7510
7540
  } catch (err) {
@@ -7539,21 +7569,24 @@ $('fsProjects').addEventListener('dblclick', (e) => {
7539
7569
  async function fsTaskRename(span, goalId){
7540
7570
  if (!goalId) return;
7541
7571
  inlineFsRename(span, async (v) => {
7542
- const r = await fetch(withKey(`/api/goals/${goalId}`), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: v }) });
7572
+ let r; try { r = await fetch(withKey(`/api/goals/${goalId}`), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: v }) }); }
7573
+ catch { showErr('Cannot rename a running/finished goal — reply to append instead'); return; }
7543
7574
  if (!r.ok) showErr('Cannot rename a running/finished goal — reply to append instead');
7544
7575
  await refresh();
7545
7576
  });
7546
7577
  }
7547
7578
  async function fsTaskArchive(goalId){
7548
7579
  if (!goalId) return;
7549
- const r = await fetch(withKey(`/api/goals/${goalId}/archive`), { method: 'POST' });
7580
+ let r; try { r = await fetch(withKey(`/api/goals/${goalId}/archive`), { method: 'POST' }); }
7581
+ catch { showErr('Archive failed.'); return; }
7550
7582
  if (!r.ok) showErr('Archive failed.'); else await refresh();
7551
7583
  }
7552
7584
  async function fsTaskDelete(goalId){
7553
7585
  if (!goalId) return;
7554
7586
  const preview = state.goals.find((x) => x.id === goalId)?.text || '';
7555
7587
  if (!(await confirmDelete({ title: 'Delete this goal?', preview }))) return;
7556
- const r = await fetch(withKey(`/api/goals/${goalId}`), { method: 'DELETE' });
7588
+ let r; try { r = await fetch(withKey(`/api/goals/${goalId}`), { method: 'DELETE' }); }
7589
+ catch { showErr('Cannot delete a running/finished goal — reply to append instead'); return; }
7557
7590
  if (!r.ok) showErr('Cannot delete a running/finished goal — reply to append instead'); else await refresh();
7558
7591
  }
7559
7592
  $('fsTodoSec').addEventListener('contextmenu', (e) => {
@@ -7587,7 +7620,7 @@ function fsSwitcherOpen(){
7587
7620
  function fsSwitcherHi(){ [...fsCtx.querySelectorAll('button')].forEach((b, i) => b.classList.toggle('hi', i === fsCtxSel)); }
7588
7621
  function fsSelectProjByIndex(idx){
7589
7622
  const rows = [...document.querySelectorAll('#fsProjects .fsproj')]; if (idx < 0 || idx >= rows.length) return;
7590
- state.active = rows[idx].dataset.fsproj; render();
7623
+ setActiveProject(rows[idx].dataset.fsproj); render();
7591
7624
  }
7592
7625
  document.addEventListener('keydown', (e) => {
7593
7626
  if (fsCtx.classList.contains('open') && fsCtxSwitcher) {
@@ -8327,7 +8360,7 @@ async function refresh(){
8327
8360
  state.workflowColumns = s.workflowColumns ?? {};
8328
8361
  state.reviewDefinitions = s.reviewDefinitions ?? {};
8329
8362
  checkUiVersion(s.uiVersion);
8330
- if (!state.projects.some((p) => p.id === state.active)) state.active = state.projects[0]?.id;
8363
+ if (!state.projects.some((p) => p.id === state.active)) setActiveProject(state.projects[0]?.id);
8331
8364
  render();
8332
8365
  } catch { /* server briefly down; SSE reconnect will retry */ }
8333
8366
  }
@@ -8337,9 +8370,12 @@ async function refresh(){
8337
8370
  // events — especially worker activity — don't saturate the main thread and drop
8338
8371
  // keystrokes while typing.
8339
8372
  let _renderT = null, _renderP = false;
8340
- function scheduleRender(){ if (_renderT) { _renderP = true; return; } render(); _renderT = setTimeout(() => { _renderT = null; if (_renderP) { _renderP = false; scheduleRender(); } }, 120); }
8373
+ // A thrown render() must not stall the throttle loop for good (live feed freeze,
8374
+ // e.g. on review reply) — catch it, log it, and keep scheduling so the next SSE
8375
+ // event still gets a paint attempt.
8376
+ function scheduleRender(){ if (_renderT) { _renderP = true; return; } try { render(); } catch (e) { console.error('[render]', e); } _renderT = setTimeout(() => { _renderT = null; if (_renderP) { _renderP = false; scheduleRender(); } }, 120); }
8341
8377
  let _actT = null, _actP = false;
8342
- function scheduleActRender(){ if (_actT) { _actP = true; return; } renderActPanel(); if (state.layout === 'flagship') renderFsFeed(); _actT = setTimeout(() => { _actT = null; if (_actP) { _actP = false; scheduleActRender(); } }, 200); }
8378
+ function scheduleActRender(){ if (_actT) { _actP = true; return; } try { renderActPanel(); if (state.layout === 'flagship') renderFsFeed(); } catch (e) { console.error('[actRender]', e); } _actT = setTimeout(() => { _actT = null; if (_actP) { _actP = false; scheduleActRender(); } }, 200); }
8343
8379
  // (connection refused = fatal, no auto-retry), so we own the reconnect loop.
8344
8380
  let esRetryMs = 1000;
8345
8381
  let sseUp = false; // live engine link — the honest signal behind the MCP tab's status dot (v46)
@@ -8412,7 +8448,7 @@ function connectSSE(){
8412
8448
  else if (m.ev === 'review-definition') state.reviewDefinitions[m.projectId] = m.reviewDefinition;
8413
8449
  else if (m.ev === 'projects') {
8414
8450
  state.projects = m.projects ?? state.projects;
8415
- if (!state.projects.some((p) => p.id === state.active)) state.active = state.projects[0]?.id;
8451
+ if (!state.projects.some((p) => p.id === state.active)) setActiveProject(state.projects[0]?.id);
8416
8452
  }
8417
8453
  else if (m.ev === 'ping') { checkUiVersion(m.uiVersion); return; }
8418
8454
  else if (m.ev === 'auth') { renderAuthBanner(m.auth); return; } // selected worker's sign-in preflight result
@@ -8448,7 +8484,7 @@ function bootDisconnected(info){
8448
8484
  applyTheme(); applyCust();
8449
8485
  // seed a minimal board so it renders like the real empty state
8450
8486
  state.projects = [{ id: '__onboard', name: 'Your project' }];
8451
- state.active = '__onboard';
8487
+ setActiveProject('__onboard');
8452
8488
  state.billing = { licensed: false, email };
8453
8489
  try { render(); } catch (e) { console.warn('disconnected render', e); }
8454
8490
  const heroSpan = document.querySelector('#fsClawdHero .hstat');
@@ -8664,15 +8700,6 @@ async function postOutboxItem(item){
8664
8700
  outbox.land(item.oid, goal.id);
8665
8701
  upsert(state.goals, goal);
8666
8702
  if (recentlySignedOut && !state.billing?.licensed) { recentlySignedOut = false; showSignInNudge(); }
8667
- // Pattern A (H23): this send became a real To-Do — mark the most recent
8668
- // unmarked user turn "→ Added to To Do #NN" in the feed. Skip a needsInput
8669
- // goal (it's still asking; shown as the question card at the feed bottom).
8670
- if (goal.status !== 'needsInput') {
8671
- const turns = state.chatTurns[item.projectId] || [];
8672
- for (let k = turns.length - 1; k >= 0; k--) {
8673
- if (turns[k].role === 'you' && turns[k].markGoalId == null) { turns[k].markGoalId = goal.id; break; }
8674
- }
8675
- }
8676
8703
  } catch {
8677
8704
  outbox.mark(item.oid, 'unsent');
8678
8705
  } finally {
@@ -8887,16 +8914,13 @@ async function send(){
8887
8914
  projectId: state.active, text, pr: 'auto', model, effort, agent: state.connectedApp,
8888
8915
  mode, skill: skill || null,
8889
8916
  workspaceMode: workspaceMode(),
8890
- // Keep the old wire contract too: only direct sends worktree:false.
8891
- worktree: worktreeOn() ? undefined : false,
8892
8917
  pending: later, review,
8893
8918
  images: attach.map((a) => a.path), imageUrls: attach.map((a) => a.url),
8894
8919
  status: 'sending', ts: Date.now(),
8895
8920
  };
8896
8921
  outbox.add(item); // saved locally BEFORE any network call
8897
8922
  // Pattern A (H23): the user's own turn enters the center feed immediately, so
8898
- // the composer reads like a conversation. If this send becomes a To-Do, a
8899
- // .cc-mark is attached to this turn later; if it's chat, a reply turn follows.
8923
+ // the composer reads like a conversation; if it's chat, a reply turn follows.
8900
8924
  (state.chatTurns[state.active] ||= []).push({ role: 'you', text, ts: Date.now() });
8901
8925
  state.attach = [];
8902
8926
  // mode/skill/later/review are per-message → clear after send (model stays sticky).
@@ -9025,9 +9049,12 @@ function renderSummaryPera(){
9025
9049
  body.querySelector('[data-peradetail]') && (body.querySelector('[data-peradetail]').onclick = () => { state.peraDetailOpen = !state.peraDetailOpen; renderSummaryPera(); });
9026
9050
  }
9027
9051
  async function peraAct(ids, kind){
9028
- for (const id of String(ids).split(',')) {
9029
- await fetch(withKey(`/api/goals/${id}/${kind}`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: kind === 'dismiss' ? JSON.stringify({ text: '' }) : undefined });
9030
- }
9052
+ try {
9053
+ for (const id of String(ids).split(',')) {
9054
+ const r = await fetch(withKey(`/api/goals/${id}/${kind}`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: kind === 'dismiss' ? JSON.stringify({ text: '' }) : undefined });
9055
+ if (!r.ok) throw new Error();
9056
+ }
9057
+ } catch { showErr(kind === 'approve' ? 'Approve failed.' : 'Dismiss failed.'); return; }
9031
9058
  if (kind === 'approve') celebrateApprove(); // confetti from the ペライチ too
9032
9059
  state.peraSpot = 0;
9033
9060
  await refresh();
@@ -9145,6 +9172,7 @@ function saRowFromDigest(r, gs, ts){
9145
9172
  goalText: String(g?.text ?? '').replace(/\s+/g, ' ').trim(),
9146
9173
  did, siblings, risk: g?.risk ?? null,
9147
9174
  verificationInfraError: g?.verificationInfraError ?? r.verificationInfraError ?? null,
9175
+ prNote: g?.prNote ?? null,
9148
9176
  note: g?.retest?.passed ? g.retest.note : null,
9149
9177
  pr: r.pr,
9150
9178
  // P0b (Masa 2026-07-21): the AI's own TodoWrite breakdown for the "Steps" section
@@ -9593,7 +9621,8 @@ function saItemHtml(it, i){
9593
9621
  const fDel = it.metaDel ?? (hasGitDiff ? `−${d.del}` : clientDiff ? `−${cDel}` : null);
9594
9622
  const chips = [];
9595
9623
  if (it.pr) chips.push(`<a class="mx mlink pr" href="${esc(it.pr)}" target="_blank" rel="noopener" title="Open pull request"><span class="mk">PR</span> <b>#${esc(prNum)}</b> <span class="mmut">Open</span>${arw}</a>`);
9596
- if (it.tokenOpt && tokPct > 0) chips.push(`<button type="button" class="mx mtok" data-saact="tok:${i}"><span class="mk">Token</span> <b>${tokPct}%</b> <span class="mmut">saved</span> <i class="tokchevron">›</i></button>`);
9624
+ const tokUntied = it.tokenOpt?.basis === 'baseline-untied';
9625
+ if (it.tokenOpt && tokPct > 0) chips.push(`<button type="button" class="mx mtok" data-saact="tok:${i}"><span class="mk">Token</span> <b>${tokPct}%</b> <span class="mmut">${tokUntied ? 'smaller' : 'saved'}</span> <i class="tokchevron">›</i></button>`);
9597
9626
  if (testsLabel) chips.push(`<span class="mx"><span class="mk">Tests</span> <b class="ok">${esc(testsLabel)}</b></span>`);
9598
9627
  // Files: hide-empty — only with changed files AND totals (a bare-count chip reads odd).
9599
9628
  if (nf && fAdd) chips.push(`<span class="mx"><span class="mk">Files</span> <b>${nf}</b> <span class="mmut">· <i class="a">${esc(fAdd)}</i> <i class="d">${esc(fDel)}</i></span></span>`);
@@ -9609,7 +9638,7 @@ function saItemHtml(it, i){
9609
9638
  if (it.recording) chips.push(it.recordingUrl
9610
9639
  ? `<a class="mx mlink" href="${esc(it.recordingUrl)}" target="_blank" rel="noopener" title="Watch the recording"><span class="mk">Verify</span> <b>recording</b>${arw}</a>`
9611
9640
  : `<a class="mx mlink" data-saact="rec:${i}" style="cursor:pointer" title="Watch the recording"><span class="mk">Verify</span> <b>recording</b>${arw}</a>`);
9612
- const tokCmp = it.tokenOpt?.basis === 'baseline' ? 'vs your recent average' : 'from prompt caching';
9641
+ const tokCmp = tokUntied ? 'vs your recent average (no technique behind it)' : it.tokenOpt?.basis === 'baseline' ? 'vs your recent average' : 'from prompt caching';
9613
9642
  const tokTech = (it.tokenOpt?.techniques ?? []).slice(0, 4).join(' / ');
9614
9643
  const metaDetail = (it.tokenOpt && tokPct > 0) ? `<div class="rmeta-detail" id="tokdetail${i}"><span class="k">How this run used tokens</span><span class="v">${esc(tokCmp)}${tokTech ? ` · ${esc(tokTech)}` : ''}</span></div>` : '';
9615
9644
  // §1.5-3: with Open it moved into the body, a card with no PR / tokens / tests has no
@@ -9726,6 +9755,15 @@ function saItemHtml(it, i){
9726
9755
  const riskHtml = risk?.shouldPause
9727
9756
  ? `<div class="lg-risk"><svg viewBox="0 0 24 24"><path d="M12 8v5M12 16.5v.5"/><path d="M10.3 3.9 2.4 18a1.9 1.9 0 0 0 1.7 2.9h15.8a1.9 1.9 0 0 0 1.7-2.9L13.7 3.9a1.9 1.9 0 0 0-3.4 0z"/></svg>Needs your review — touches a sensitive area${risk.categories?.length ? `: ${esc(risk.categories.map((c) => c.label).join(', '))}` : ''}</div>`
9728
9757
  : '';
9758
+ // ── PR-requested-but-no-code note (goal #751, Masa 2026-07-26): a wantsPR goal whose
9759
+ // worker ended with zero changed files and no proof — most often a lane-conflict
9760
+ // report ("空き次第自動再開します") that could not actually resume, since a `claude -p`
9761
+ // run is one-shot. Advisory only (isNothingVerifiableForPR never blocks — Failedを作ら
9762
+ // ない方針を尊重), so the reviewer sees it and decides instead of it silently reading
9763
+ // as a normal completed PR review. ──
9764
+ const prNoteHtml = it.prNote
9765
+ ? `<div class="lg-risk"><svg viewBox="0 0 24 24"><path d="M12 8v5M12 16.5v.5"/><path d="M10.3 3.9 2.4 18a1.9 1.9 0 0 0 1.7 2.9h15.8a1.9 1.9 0 0 0 1.7-2.9L13.7 3.9a1.9 1.9 0 0 0-3.4 0z"/></svg>${esc(it.prNote)}</div>`
9766
+ : '';
9729
9767
  // ── Activity (spec §3): ONE Linear-docs pulldown. The "Activity" heading is plain; the
9730
9768
  // chevron sits on the FIRST line (the opening "You …" row); tapping it opens the rest
9731
9769
  // below, offset-indented. The review Discussion folds in at the bottom of the body. The
@@ -9817,6 +9855,7 @@ function saItemHtml(it, i){
9817
9855
  ${S('Proof', proofInner)}
9818
9856
  ${S('What changed', changedBody)}
9819
9857
  ${riskHtml}
9858
+ ${prNoteHtml}
9820
9859
  ${conversationSec}
9821
9860
  ${activitySec}
9822
9861
  ${S('Workflow', saWorkflowHtml(it))}
@@ -10072,13 +10111,32 @@ function saMoveFeedbackOutOfReview(it, body){
10072
10111
  if (task?.id) upsert(state.tasks, task);
10073
10112
  const at = SA.items.findIndex((row) => row.goalId === it.goalId);
10074
10113
  const next = at >= 0 ? (SA.items[at + 1] || SA.items[at - 1]) : null;
10114
+ // Was the reviewer actually looking at the card that just left Review (vs having already
10115
+ // scrolled past it to read another one)? If it's fully above the visible scroll area, the
10116
+ // reviewer is reading something else — snapping the view to the next card's top would yank
10117
+ // it out from under them (Masa 2026-07-26: replying to the card above and having the one
10118
+ // you're reading jump to the top was reported as stressful).
10119
+ const bodyEl = document.querySelector('#seeall .sa-body');
10120
+ const cardEl = saCardEl(it.goalId);
10121
+ const priorScrollTop = bodyEl?.scrollTop ?? 0;
10122
+ const removedHeight = cardEl ? cardEl.getBoundingClientRect().height : 0;
10123
+ const cardAboveViewport = !!(cardEl && bodyEl && cardEl.getBoundingClientRect().bottom <= bodyEl.getBoundingClientRect().top + 1);
10075
10124
  if (at >= 0) SA.items.splice(at, 1);
10076
10125
  render();
10077
10126
  if (SA.solo === it.goalId || !SA.items.length) { saRender(); saClose(); return; }
10078
10127
  SA.cur = next?.goalId ?? SA.items[0]?.goalId ?? null;
10079
- SA.focusGid = SA.cur;
10080
- SA.focusScrolled = false;
10081
- saRender();
10128
+ if (cardAboveViewport) {
10129
+ // Keep reading where we were: compensate for the removed card's height instead of
10130
+ // forcing a scroll-to-top of whatever is now "current".
10131
+ SA.focusGid = null;
10132
+ saRender();
10133
+ const newBody = document.querySelector('#seeall .sa-body');
10134
+ if (newBody) newBody.scrollTop = Math.max(0, priorScrollTop - removedHeight);
10135
+ } else {
10136
+ SA.focusGid = SA.cur;
10137
+ SA.focusScrolled = false;
10138
+ saRender();
10139
+ }
10082
10140
  }
10083
10141
  function saCardEl(goalId){ return document.querySelector(`#seeall .sa-item[data-gid="${goalId}"]`); }
10084
10142
  // Flip one card to its "Working on it" head without redrawing anything else (決定2).
@@ -10262,7 +10320,8 @@ $('seeall').addEventListener('click', async (e) => {
10262
10320
  // SA.items[i] lookup below.
10263
10321
  if (k === 'foldopen') return openReviewChecklist(Number(v));
10264
10322
  if (k === 'foldundo') {
10265
- const r = await fetch(withKey(`/api/goals/${v}/unfold`), { method: 'POST' });
10323
+ let r; try { r = await fetch(withKey(`/api/goals/${v}/unfold`), { method: 'POST' }); }
10324
+ catch { showErr('Undo failed.'); return; }
10266
10325
  if (r && r.ok) await refresh(); else showErr('Undo failed.');
10267
10326
  return;
10268
10327
  }
@@ -10275,7 +10334,8 @@ $('seeall').addEventListener('click', async (e) => {
10275
10334
  if (k === 'reverify') {
10276
10335
  b.disabled = true;
10277
10336
  b.textContent = '再検証中…';
10278
- const r = await fetch(withKey(`/api/goals/${it.goalId}/reverify`), { method: 'POST' });
10337
+ let r; try { r = await fetch(withKey(`/api/goals/${it.goalId}/reverify`), { method: 'POST' }); }
10338
+ catch { showErr('再検証に失敗'); b.disabled = false; return; }
10279
10339
  if (r.ok) await refresh(); else showErr('再検証に失敗');
10280
10340
  return;
10281
10341
  }
@@ -10460,7 +10520,6 @@ function renderCtxBar(){
10460
10520
  const grp = $('ctxbrgrp');
10461
10521
  if (grp) grp.style.display = branch ? '' : 'none';
10462
10522
  const bn = $('ctxbranchname'); if (bn) bn.textContent = branch;
10463
- renderWorktreeChip();
10464
10523
  const menu = $('ctxdirmenu'); if (!menu) return;
10465
10524
  const cur = dir;
10466
10525
  const opts = (state.repos ?? [])
@@ -10472,31 +10531,12 @@ function renderCtxBar(){
10472
10531
  const openRow = `<button type="button" class="appopt ctxopen" id="ctxopenfolder" title="Open the OS folder chooser"><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-2z"/><path d="M12 11v6M9 14h6"/></svg><span class="nm">Open folder…</span></button>`;
10473
10532
  menu.innerHTML = `${opts}${openRow}<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>`;
10474
10533
  }
10475
- // Workspace mode. isolated worktree is the default clean copy; direct workspace inherits
10476
- // and edits the current folder. Sticky per project, like model and effort, because it is
10477
- // a working habit rather than a per-message thought.
10478
- function worktreeKey(){ return `sel:worktree:${state.active}`; }
10479
- function worktreeOn(){ return localStorage.getItem(worktreeKey()) !== '0'; }
10480
- function workspaceMode(){ return worktreeOn() ? 'isolated-worktree' : 'direct-workspace'; }
10481
- function goalWorkspaceMode(g){ return (g?.workspaceMode === 'direct-workspace' || g?.worktree === false) ? 'direct-workspace' : 'isolated-worktree'; }
10482
- function workspaceModeText(g){ return goalWorkspaceMode(g) === 'direct-workspace' ? 'direct workspace' : 'isolated worktree'; }
10483
- function renderWorktreeChip(){
10484
- const el = $('ctxwt'); if (!el) return;
10485
- const isolated = worktreeOn();
10486
- const proj = state.projects.find((p) => p.id === state.active);
10487
- el.setAttribute('aria-checked', isolated ? 'true' : 'false');
10488
- const label = $('ctxwtlabel');
10489
- if (label) label.textContent = isolated ? 'isolated worktree' : 'direct workspace';
10490
- el.title = isolated
10491
- ? 'isolated worktree: default clean copy; this folder is not edited.'
10492
- : 'direct workspace: inherits the current state and edits this folder.';
10493
- const warn = $('ctxworkwarn');
10494
- if (warn) {
10495
- const dirty = !isolated && !!proj?.dirty;
10496
- warn.hidden = !dirty;
10497
- warn.textContent = dirty ? `Dirty workspace warning: direct workspace will edit ${folderName(proj?.dir) || 'this folder'} as it is now.` : '';
10498
- }
10499
- }
10534
+ // Workspace mode is fixed to the isolated worktree (clean separate checkout). The
10535
+ // direct-workspace escape hatch was removed after two accidents where a stray click
10536
+ // edited a shared checkout directly; there was no real upside to keep it around for.
10537
+ function workspaceMode(){ return 'isolated-worktree'; }
10538
+ function goalWorkspaceMode(){ return 'isolated-worktree'; }
10539
+ function workspaceModeText(){ return 'isolated worktree'; }
10500
10540
  // Asked for once, and only when the menu is first opened — the same "only when you look
10501
10541
  // at it" rule the implementation log and the conversation follow.
10502
10542
  async function loadRepos(){
@@ -10557,11 +10597,6 @@ $('ctxdirbtn').onclick = (e) => {
10557
10597
  menu.classList.toggle('show');
10558
10598
  if (opening) { renderCtxBar(); loadRepos(); } // no-op once boot's fetch has landed
10559
10599
  };
10560
- $('ctxwt').onclick = (e) => {
10561
- e.stopPropagation();
10562
- localStorage.setItem(worktreeKey(), worktreeOn() ? '0' : '1');
10563
- renderWorktreeChip();
10564
- };
10565
10600
  $('ctxdirmenu').addEventListener('click', (e) => {
10566
10601
  e.stopPropagation();
10567
10602
  if (e.target.closest('#ctxopenfolder')) { openFolderNative(); return; }
@@ -11710,6 +11745,7 @@ function buildGearPop(){
11710
11745
  if (!(await confirmDelete({ title: 'Log out of Google?', body: 'You can sign back in anytime.', confirmLabel: 'Log out', confirmDanger: false }))) return;
11711
11746
  signOutBtn.disabled = true;
11712
11747
  try { await fetch(withKey('/api/signout'), { method: 'POST' }); recentlySignedOut = true; }
11748
+ catch {}
11713
11749
  finally { await refresh(); if ($('gearPop')) buildGearPop(); }
11714
11750
  };
11715
11751
  const rulesBtn = pop.querySelector('[data-grules]');