@galda/cli 0.10.71 → 0.10.73
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 +245 -191
- package/engine/lib.mjs +115 -9
- package/engine/server.mjs +83 -4
- package/package.json +1 -1
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
|
|
@@ -1507,15 +1492,6 @@
|
|
|
1507
1492
|
background:var(--ink);animation:ccblink 1.05s steps(1) infinite}
|
|
1508
1493
|
@keyframes ccblink{50%{opacity:0}}
|
|
1509
1494
|
@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
1495
|
/* Claude Code CLI language (HANDOFF-v45 §12, flagship .cc-t port): gap:0 — the bullet's
|
|
1520
1496
|
own margin-right does the spacing, not a flex gap (keeps ⏺ tight against the tool name
|
|
1521
1497
|
the way the real CLI reads). */
|
|
@@ -2120,6 +2096,12 @@
|
|
|
2120
2096
|
/* the note is body prose like every other section's — same 14px / −.012em / --ink as
|
|
2121
2097
|
.rbody (Masa 2026-07-22: it was a shade lighter and read as a different kind of text) */
|
|
2122
2098
|
.redesign .ropen .v{margin:4px 0 0 !important}
|
|
2099
|
+
/* A malformed .manager-run.json advisory (goal #733, Masa 2026-07-26): plain
|
|
2100
|
+
danger-red text, no box — this must not read as calm/resolved like a normal
|
|
2101
|
+
note (Masa: "めっちゃdoneっぽい"). Same --danger token .prfail already uses
|
|
2102
|
+
for a blocked PR, so it speaks the SAME "needs your attention" language the
|
|
2103
|
+
app already has, rather than inventing a new one. */
|
|
2104
|
+
.redesign .ropen.rdeclerr .v{color:var(--danger) !important}
|
|
2123
2105
|
/* sections — Linear-docs bold sentence-case headings + high-contrast body.
|
|
2124
2106
|
margins are !important: the app's pre-existing .lg / .sa-item rules load first and
|
|
2125
2107
|
otherwise tie/beat these on specificity, flattening the Linear-docs rhythm. */
|
|
@@ -2128,6 +2110,10 @@
|
|
|
2128
2110
|
/* body prose = the app's normal text: 14px / −.012em, matching .bubble (Masa 2026-07-20:
|
|
2129
2111
|
13px read thinner/lighter for Japanese than the surrounding app). §2. */
|
|
2130
2112
|
.redesign .rbody{font-size:14px;line-height:1.62;letter-spacing:-.012em;color:var(--ink);max-width:70ch;margin:0 !important}
|
|
2113
|
+
/* Request text is the human's own free-form message (goal #431): it can contain blank
|
|
2114
|
+
lines and numbered lists. esc() already neutralizes HTML, so pre-wrap just keeps the
|
|
2115
|
+
line breaks the person actually typed instead of HTML collapsing them into one line. */
|
|
2116
|
+
.redesign .rbody.rask{white-space:pre-wrap}
|
|
2131
2117
|
.redesign .rinfra{padding:10px 12px;border:1px solid rgba(var(--amber-rgb),.35);border-radius:8px;background:rgba(var(--amber-rgb),.06)}
|
|
2132
2118
|
.redesign .rinfra .rbody{color:var(--ink2)}
|
|
2133
2119
|
.redesign .rinfra .retrybtn{margin-top:8px;margin-left:0}
|
|
@@ -2976,13 +2962,8 @@
|
|
|
2976
2962
|
<div class="ctxchip ctxbrgrp" id="ctxbrgrp">
|
|
2977
2963
|
<span class="ctxseg ctxbranch" id="ctxbranch" title="Branch this folder is on">
|
|
2978
2964
|
<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>
|
|
2979
|
-
<span class="ctxdiv"></span>
|
|
2980
|
-
<button type="button" class="ctxseg ctxwt" id="ctxwt" role="checkbox" aria-checked="true"
|
|
2981
|
-
title="isolated worktree: default clean copy. direct workspace: inherits and edits this folder.">
|
|
2982
|
-
<i class="ctxbox"></i><span id="ctxwtlabel">isolated worktree</span></button>
|
|
2983
2965
|
</div>
|
|
2984
2966
|
</div>
|
|
2985
|
-
<div class="ctxworkwarn" id="ctxworkwarn" hidden></div>
|
|
2986
2967
|
<!-- Conversation-first composer (SLICE 2, docs/HANDOFF-ONBOARDING-conversational.md):
|
|
2987
2968
|
a chat/help/settings message gets a quiet reply HERE instead of
|
|
2988
2969
|
becoming a To-Do. Reuses the .peraanswer surface; hidden until a
|
|
@@ -3369,8 +3350,20 @@ const savedTheme = (() => {
|
|
|
3369
3350
|
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,
|
|
3370
3351
|
// per-message send overrides set via slash-commands (reset after each send,
|
|
3371
3352
|
// except model which is sticky in localStorage). palette = open command list.
|
|
3353
|
+
// Scoped per project (msgByProject) so a skill/mode picked in one project can't leak
|
|
3354
|
+
// into another one just switched to — state.msg is always the CURRENT project's slot;
|
|
3355
|
+
// switching projects must go through setActiveProject(), never a bare state.active = .
|
|
3372
3356
|
msg: { mode: null, model: null, effort: null, skill: null, later: false, review: false },
|
|
3357
|
+
msgByProject: {},
|
|
3373
3358
|
stickyModel: localStorage.getItem('sel:model') || 'sonnet', stickyEffort: localStorage.getItem('sel:effort') || 'medium', palette: null };
|
|
3359
|
+
state.msgByProject[state.active] = state.msg;
|
|
3360
|
+
function emptyMsg(){ return { mode: null, model: null, effort: null, skill: null, later: false, review: false }; }
|
|
3361
|
+
function setActiveProject(id){
|
|
3362
|
+
if (id === state.active) return;
|
|
3363
|
+
state.msgByProject[state.active] = state.msg;
|
|
3364
|
+
state.active = id;
|
|
3365
|
+
state.msg = state.msgByProject[id] || (state.msgByProject[id] = emptyMsg());
|
|
3366
|
+
}
|
|
3374
3367
|
window.state = state; // top-level const isn't a window property; external checks probe window.state
|
|
3375
3368
|
const $ = (id) => document.getElementById(id);
|
|
3376
3369
|
|
|
@@ -3895,10 +3888,13 @@ function tokenBaselinePerRequirementLocal(history){
|
|
|
3895
3888
|
return samples.reduce((a, b) => a + b, 0) / samples.length;
|
|
3896
3889
|
}
|
|
3897
3890
|
// Mirrors engine/lib.mjs estimateTokenOptimization() — see the comment there
|
|
3898
|
-
// for why
|
|
3899
|
-
//
|
|
3900
|
-
//
|
|
3901
|
-
//
|
|
3891
|
+
// for why a positive `baseline` percent is only labeled "saved" (basis:
|
|
3892
|
+
// 'baseline') when a real technique fired this run (cache reuse, a fresh
|
|
3893
|
+
// handoff, /compact, /clear). Without one, the same comparison ships as
|
|
3894
|
+
// basis: 'baseline-untied' — a true fact (smaller/larger than usual) but
|
|
3895
|
+
// not a claim that anything the user did caused it (Masa 2026-07-26; a
|
|
3896
|
+
// small goal right after big ones scored a huge "saved" purely from the
|
|
3897
|
+
// size mix). With no history at all it's basis: 'no-baseline' (no number).
|
|
3902
3898
|
function estimateTokenOptimizationLocal({ usage = null, weightedTokens = null, cacheReadWeight = 0.1, sessionMode = null, usedHandoff = false, usedCompact = false, usedClear = false, agentModel = null, requirementCount = 1, history = [] } = {}){
|
|
3903
3899
|
const u = usage ?? {};
|
|
3904
3900
|
const raw = (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0);
|
|
@@ -3907,15 +3903,11 @@ function estimateTokenOptimizationLocal({ usage = null, weightedTokens = null, c
|
|
|
3907
3903
|
const reqN = Math.max(1, Number(requirementCount) || 1);
|
|
3908
3904
|
const perReq = weighted / reqN;
|
|
3909
3905
|
const baseline = tokenBaselinePerRequirementLocal(history);
|
|
3910
|
-
let percent, basis, savedTokens;
|
|
3906
|
+
let percent = null, basis = 'no-baseline', savedTokens = null;
|
|
3911
3907
|
if (baseline != null) {
|
|
3912
3908
|
percent = Math.max(-100, Math.min(100, Math.round(((baseline - perReq) / baseline) * 100)));
|
|
3913
3909
|
basis = 'baseline';
|
|
3914
3910
|
savedTokens = Math.max(0, Math.round((baseline - perReq) * reqN));
|
|
3915
|
-
} else {
|
|
3916
|
-
savedTokens = Math.max(0, raw - weighted);
|
|
3917
|
-
percent = Math.round((savedTokens / raw) * 100);
|
|
3918
|
-
basis = 'cache-weight';
|
|
3919
3911
|
}
|
|
3920
3912
|
const techniques = [];
|
|
3921
3913
|
const cacheRatio = Math.round(((u.cache_read_input_tokens ?? 0) / raw) * 100);
|
|
@@ -3923,8 +3915,10 @@ function estimateTokenOptimizationLocal({ usage = null, weightedTokens = null, c
|
|
|
3923
3915
|
if (usedHandoff || sessionMode === 'cold-handoff') techniques.push('fresh handoff instead of a bloated session');
|
|
3924
3916
|
if (usedCompact || sessionMode === 'compact') techniques.push('/compact before continuing');
|
|
3925
3917
|
if (usedClear) techniques.push('/clear between unrelated goals');
|
|
3926
|
-
|
|
3918
|
+
const techniqueDriven = cacheRatio > 0 || usedHandoff || sessionMode === 'cold-handoff' || usedCompact || sessionMode === 'compact' || usedClear;
|
|
3919
|
+
if (basis === 'baseline' && !techniqueDriven) basis = 'baseline-untied';
|
|
3927
3920
|
if (basis === 'baseline' && percent > 0) techniques.push(`${Math.round(perReq / 1000)}k tokens/requirement vs your ${Math.round(baseline / 1000)}k average`);
|
|
3921
|
+
if (basis === 'baseline-untied') techniques.push(`${Math.round(perReq / 1000)}k tokens/requirement vs your ${Math.round(baseline / 1000)}k average`);
|
|
3928
3922
|
if (!techniques.length) techniques.push(`${Math.round(raw / 1000)}k tokens this run (no cache reuse yet)`);
|
|
3929
3923
|
return { rawTokens: raw, weightedTokens: weighted, savedTokens, percent, basis, techniques: [...new Set(techniques)] };
|
|
3930
3924
|
}
|
|
@@ -3932,6 +3926,10 @@ function tokenOptimizationHtml(opt){
|
|
|
3932
3926
|
if (!opt) return '';
|
|
3933
3927
|
const techniques = opt.techniques?.length ? opt.techniques.slice(0, 4).join(' / ') : 'this run';
|
|
3934
3928
|
const pct = Number(opt.percent);
|
|
3929
|
+
if (opt.basis === 'baseline-untied') {
|
|
3930
|
+
const dir = pct > 0 ? 'smaller' : 'larger';
|
|
3931
|
+
return `<div class="rvtokenopt">${esc(`Token usage: this run was ${dir} than your recent average (${techniques}) — no cache/compact/handoff technique behind it`)}</div>`;
|
|
3932
|
+
}
|
|
3935
3933
|
const cmp = opt.basis === 'baseline' ? 'vs your recent average' : 'from prompt caching';
|
|
3936
3934
|
const text = pct > 0
|
|
3937
3935
|
? `Token optimized: saved about ${pct}% ${cmp} (${techniques})`
|
|
@@ -3943,7 +3941,7 @@ function renderProjects(){
|
|
|
3943
3941
|
const busy = state.tasks.filter((t) => t.projectId === p.id && ['running','queued'].includes(t.status)).length;
|
|
3944
3942
|
return `<button class="proj ${p.id === state.active ? 'active' : ''}" data-id="${p.id}">${esc(p.name)}${busy ? `<span class="n">${busy}</span>` : ''}</button>`;
|
|
3945
3943
|
}).join('');
|
|
3946
|
-
for (const b of document.querySelectorAll('.proj')) b.onclick = () => {
|
|
3944
|
+
for (const b of document.querySelectorAll('.proj')) b.onclick = () => { setActiveProject(b.dataset.id); render(); };
|
|
3947
3945
|
}
|
|
3948
3946
|
|
|
3949
3947
|
function proofMediaHtml(taskId, proof, altLabel){
|
|
@@ -4366,8 +4364,9 @@ function renderStream(){
|
|
|
4366
4364
|
inp.addEventListener('keydown', async (e) => {
|
|
4367
4365
|
if (e.key !== 'Enter' || e.isComposing || !inp.value.trim()) return;
|
|
4368
4366
|
const text = inp.value.trim(); inp.value = ''; inp.disabled = true;
|
|
4369
|
-
|
|
4370
|
-
headers: { 'content-type': 'application/json' }, body: JSON.stringify(replyWorkerPayload({ text })) });
|
|
4367
|
+
let r; try { r = await fetch(withKey(`/api/goals/${inp.dataset.goal}/reply`), { method: 'POST',
|
|
4368
|
+
headers: { 'content-type': 'application/json' }, body: JSON.stringify(replyWorkerPayload({ text })) }); }
|
|
4369
|
+
catch { showErr('Reply failed — the goal may not have started yet.'); inp.disabled = false; return; }
|
|
4371
4370
|
if (!r.ok) showErr('Reply failed — the goal may not have started yet.');
|
|
4372
4371
|
inp.disabled = false;
|
|
4373
4372
|
});
|
|
@@ -4492,18 +4491,22 @@ function renderQueue(){
|
|
|
4492
4491
|
const text = document.getElementById(`qedit-${id}`).value.trim();
|
|
4493
4492
|
const pr = document.getElementById(`qedit-pr-${id}`).dataset.val;
|
|
4494
4493
|
if (text) {
|
|
4495
|
-
|
|
4496
|
-
|
|
4494
|
+
try {
|
|
4495
|
+
const r = await fetch(withKey(`/api/goals/${id}`), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, pr }) });
|
|
4496
|
+
if (r.ok) upsert(state.goals, await r.json()); else showErr('Save failed.');
|
|
4497
|
+
} catch { showErr('Save failed.'); }
|
|
4497
4498
|
}
|
|
4498
4499
|
state.editing = null; render();
|
|
4499
4500
|
};
|
|
4500
4501
|
for (const b of q.querySelectorAll('.qp')) b.onclick = async () => {
|
|
4501
|
-
|
|
4502
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.id}`), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ promote: true }) }); }
|
|
4503
|
+
catch { showErr('Too late — this goal already started.'); render(); return; }
|
|
4502
4504
|
if (r.ok) upsert(state.goals, await r.json()); else showErr('Too late — this goal already started.');
|
|
4503
4505
|
render();
|
|
4504
4506
|
};
|
|
4505
4507
|
for (const b of q.querySelectorAll('.qd')) b.onclick = async () => {
|
|
4506
|
-
|
|
4508
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.id}`), { method: 'DELETE' }); }
|
|
4509
|
+
catch { showErr('Too late — this goal already started.'); await refresh(); render(); return; }
|
|
4507
4510
|
if (r.ok) state.goals = state.goals.filter((g) => g.id !== Number(b.dataset.id));
|
|
4508
4511
|
else { showErr('Too late — this goal already started.'); await refresh(); }
|
|
4509
4512
|
render();
|
|
@@ -4718,15 +4721,19 @@ function renderTasks(){
|
|
|
4718
4721
|
const id = Number(el.dataset.task);
|
|
4719
4722
|
const rb = e.target.closest('[data-retrybtn]');
|
|
4720
4723
|
if (rb) {
|
|
4721
|
-
|
|
4722
|
-
|
|
4724
|
+
try {
|
|
4725
|
+
const r = await fetch(withKey(`/api/tasks/${rb.dataset.retrybtn}/retry`), { method: 'POST' });
|
|
4726
|
+
if (!r.ok) showErr('Retry failed.'); else await refresh();
|
|
4727
|
+
} catch { showErr('Retry failed.'); }
|
|
4723
4728
|
return;
|
|
4724
4729
|
}
|
|
4725
4730
|
const sb = e.target.closest('[data-skipbtn]');
|
|
4726
4731
|
if (sb) {
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
4732
|
+
try {
|
|
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();
|
|
4736
|
+
} catch { showErr('Dismiss failed.'); }
|
|
4730
4737
|
return;
|
|
4731
4738
|
}
|
|
4732
4739
|
// Attention rows open the same detail dialog as Review (tap anywhere but
|
|
@@ -4770,11 +4777,13 @@ function renderTasks(){
|
|
|
4770
4777
|
b.onclick = async (e) => { e.stopPropagation();
|
|
4771
4778
|
const bx = b.getBoundingClientRect(); // capture before await/re-render — burst FROM the ✓ button
|
|
4772
4779
|
let ok = true, mergeGoal = null;
|
|
4773
|
-
|
|
4774
|
-
const
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
|
|
4780
|
+
try {
|
|
4781
|
+
for (const id of b.dataset.qapprove.split(',')) {
|
|
4782
|
+
const r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
|
|
4783
|
+
if (!r.ok){ ok = false; break; }
|
|
4784
|
+
const j = await r.json(); if (j && j.needsMerge) mergeGoal = j.goal; // 07-20c: PR goal
|
|
4785
|
+
}
|
|
4786
|
+
} catch { ok = false; }
|
|
4778
4787
|
if (!ok){ showErr('Approve failed.'); return; }
|
|
4779
4788
|
await refresh();
|
|
4780
4789
|
if (mergeGoal){ confirmMerge(mergeGoal); return; } // merge instead of confetti
|
|
@@ -4784,19 +4793,24 @@ function renderTasks(){
|
|
|
4784
4793
|
// 07-20c: the inline ✕ is a pure Reject — park the goal in the Rejected column (/reject),
|
|
4785
4794
|
// no auto-rework. Rework is asked for via chat.
|
|
4786
4795
|
b.onclick = async (e) => { e.stopPropagation();
|
|
4787
|
-
let r; for (const id of b.dataset.qdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/reject`), { method: 'POST' });
|
|
4796
|
+
let r; try { for (const id of b.dataset.qdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/reject`), { method: 'POST' }); }
|
|
4797
|
+
catch { showErr('Dismiss failed.'); return; }
|
|
4788
4798
|
qrefresh(r, 'Dismiss failed.'); };
|
|
4789
4799
|
}
|
|
4790
4800
|
// Archive: retire an unfinished goal cleanly (no rework spawned). The escape
|
|
4791
4801
|
// hatch for half-finished (partial/blocked) items so Attention stays drainable.
|
|
4792
4802
|
for (const b of $('tasklist').querySelectorAll('[data-archivegoal]')) {
|
|
4793
4803
|
b.onclick = async (e) => { e.stopPropagation();
|
|
4794
|
-
|
|
4804
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.archivegoal}/archive`), { method: 'POST' }); }
|
|
4805
|
+
catch { showErr('Archive failed.'); return; }
|
|
4806
|
+
qrefresh(r, 'Archive failed.'); };
|
|
4795
4807
|
}
|
|
4796
4808
|
// Manager-verified gate: re-run tests + re-capture proof for a blocked goal.
|
|
4797
4809
|
for (const b of $('tasklist').querySelectorAll('[data-goalreverify]')) {
|
|
4798
4810
|
b.onclick = async (e) => { e.stopPropagation(); b.disabled = true; b.textContent = '再検証中…';
|
|
4799
|
-
|
|
4811
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.goalreverify}/reverify`), { method: 'POST' }); }
|
|
4812
|
+
catch { showErr('再検証に失敗'); return; }
|
|
4813
|
+
qrefresh(r, '再検証に失敗'); };
|
|
4800
4814
|
}
|
|
4801
4815
|
for (const b of $('tasklist').querySelectorAll('[data-qreply]')) {
|
|
4802
4816
|
b.onclick = (e) => { e.stopPropagation();
|
|
@@ -4839,14 +4853,16 @@ function renderTasks(){
|
|
|
4839
4853
|
for (const b of $('tasklist').querySelectorAll('[data-pstart]')) {
|
|
4840
4854
|
b.onclick = async (e) => {
|
|
4841
4855
|
e.stopPropagation();
|
|
4842
|
-
|
|
4856
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.pstart}/activate`), { method: 'POST' }); }
|
|
4857
|
+
catch { showErr('Start failed.'); return; }
|
|
4843
4858
|
if (!r.ok) showErr('Start failed.'); else await refresh();
|
|
4844
4859
|
};
|
|
4845
4860
|
}
|
|
4846
4861
|
for (const b of $('tasklist').querySelectorAll('[data-pdel]')) {
|
|
4847
4862
|
b.onclick = async (e) => {
|
|
4848
4863
|
e.stopPropagation();
|
|
4849
|
-
|
|
4864
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.pdel}`), { method: 'DELETE' }); }
|
|
4865
|
+
catch { showErr('Delete failed.'); return; }
|
|
4850
4866
|
if (!r.ok) showErr('Delete failed.'); else await refresh();
|
|
4851
4867
|
};
|
|
4852
4868
|
}
|
|
@@ -4858,13 +4874,15 @@ function renderTasks(){
|
|
|
4858
4874
|
e.stopPropagation();
|
|
4859
4875
|
const preview = b.closest('.goalhead')?.querySelector('.gt')?.textContent || '';
|
|
4860
4876
|
if (!(await confirmDelete({ title: 'Delete this goal?', preview }))) return;
|
|
4861
|
-
|
|
4877
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.ghdel}`), { method: 'DELETE' }); }
|
|
4878
|
+
catch { showErr('Cannot delete a running/finished goal — reply to append instead'); return; }
|
|
4862
4879
|
if (!r.ok) showErr('Cannot delete a running/finished goal — reply to append instead'); else await refresh();
|
|
4863
4880
|
};
|
|
4864
4881
|
// Smart intake: answer a planner clarifying question (chip or free text) → re-plan.
|
|
4865
4882
|
const answerQ = async (id, answer) => {
|
|
4866
4883
|
if (!answer) return;
|
|
4867
|
-
|
|
4884
|
+
let r; try { r = await fetch(withKey(`/api/goals/${id}/answer`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ answer }) }); }
|
|
4885
|
+
catch { showErr('Failed to send answer'); return; }
|
|
4868
4886
|
if (!r.ok) showErr('Failed to send answer'); else await refresh();
|
|
4869
4887
|
};
|
|
4870
4888
|
for (const b of $('tasklist').querySelectorAll('[data-qanswer]')) b.onclick = (e) => { e.stopPropagation(); answerQ(Number(b.dataset.qanswer), b.dataset.answer); };
|
|
@@ -4961,7 +4979,8 @@ function openGoalheadReply(id, btn){
|
|
|
4961
4979
|
const snd = document.createElement('button'); snd.className = 'qbtn ok'; snd.textContent = '↑';
|
|
4962
4980
|
wrap.appendChild(inp); wrap.appendChild(snd); head.insertAdjacentElement('afterend', wrap); inp.focus();
|
|
4963
4981
|
const send = async () => { const t = inp.value.trim(); if (!t) return;
|
|
4964
|
-
|
|
4982
|
+
let r; try { r = await fetch(withKey(`/api/goals/${id}/reply`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(replyWorkerPayload({ text: t })) }); }
|
|
4983
|
+
catch { showErr('未開始のゴールは編集を使ってください'); return; }
|
|
4965
4984
|
if (!r.ok) showErr('未開始のゴールは編集を使ってください'); await refresh(); };
|
|
4966
4985
|
inp.onkeydown = (e) => { if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); send(); } if (e.key === 'Escape') wrap.remove(); };
|
|
4967
4986
|
snd.onclick = send;
|
|
@@ -4984,9 +5003,11 @@ function wirePriority(){
|
|
|
4984
5003
|
b.onclick = async (e) => {
|
|
4985
5004
|
e.stopPropagation();
|
|
4986
5005
|
b.closest('.priomenu')?.classList.remove('show');
|
|
4987
|
-
|
|
4988
|
-
|
|
4989
|
-
|
|
5006
|
+
try {
|
|
5007
|
+
const r = await fetch(withKey(`/api/tasks/${b.dataset.priotask}/priority`), { method: 'PUT',
|
|
5008
|
+
headers: { 'content-type': 'application/json' }, body: JSON.stringify({ priority: b.dataset.val }) });
|
|
5009
|
+
if (!r.ok) showErr('優先度の変更に失敗しました。'); else await refresh();
|
|
5010
|
+
} catch { showErr('優先度の変更に失敗しました。'); }
|
|
4990
5011
|
};
|
|
4991
5012
|
}
|
|
4992
5013
|
}
|
|
@@ -5182,26 +5203,31 @@ $('wfHeaderEdit').onclick = (e) => { e.stopPropagation(); openWfEditor(); };
|
|
|
5182
5203
|
$('wfoverlay').addEventListener('click', (e) => { if (e.target === $('wfoverlay')) $('wfoverlay').classList.remove('show'); });
|
|
5183
5204
|
$('wfsave').onclick = async () => {
|
|
5184
5205
|
const columns = state.wfDraft.map((c) => ({ key: c.key, label: c.label.trim(), bucket: c.bucket }));
|
|
5185
|
-
|
|
5186
|
-
|
|
5187
|
-
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
5193
|
-
|
|
5194
|
-
|
|
5195
|
-
|
|
5196
|
-
|
|
5197
|
-
|
|
5206
|
+
try {
|
|
5207
|
+
const r = await fetch(withKey(`/api/projects/${state.active}/columns`), { method: 'PUT',
|
|
5208
|
+
headers: { 'content-type': 'application/json' }, body: JSON.stringify({ columns }) });
|
|
5209
|
+
const data = await r.json().catch(() => ({}));
|
|
5210
|
+
if (!r.ok) {
|
|
5211
|
+
$('wferr').textContent = data.error || 'Save failed.';
|
|
5212
|
+
$('wferr').style.display = 'block';
|
|
5213
|
+
return;
|
|
5214
|
+
}
|
|
5215
|
+
const rdr = await fetch(withKey(`/api/projects/${state.active}/review-definition`), { method: 'PUT',
|
|
5216
|
+
headers: { 'content-type': 'application/json' }, body: JSON.stringify({ reviewDefinition: state.rdDraft }) });
|
|
5217
|
+
const rdData = await rdr.json().catch(() => ({}));
|
|
5218
|
+
if (!rdr.ok) {
|
|
5219
|
+
$('wferr').textContent = rdData.error || 'Save failed.';
|
|
5220
|
+
$('wferr').style.display = 'block';
|
|
5221
|
+
return;
|
|
5222
|
+
}
|
|
5223
|
+
state.workflowColumns[state.active] = data.columns;
|
|
5224
|
+
state.reviewDefinitions[state.active] = rdData.reviewDefinition;
|
|
5225
|
+
$('wfoverlay').classList.remove('show');
|
|
5226
|
+
render();
|
|
5227
|
+
} catch {
|
|
5228
|
+
$('wferr').textContent = 'Save failed.';
|
|
5198
5229
|
$('wferr').style.display = 'block';
|
|
5199
|
-
return;
|
|
5200
5230
|
}
|
|
5201
|
-
state.workflowColumns[state.active] = data.columns;
|
|
5202
|
-
state.reviewDefinitions[state.active] = rdData.reviewDefinition;
|
|
5203
|
-
$('wfoverlay').classList.remove('show');
|
|
5204
|
-
render();
|
|
5205
5231
|
};
|
|
5206
5232
|
|
|
5207
5233
|
function wireWfEditButtons(){
|
|
@@ -5426,7 +5452,8 @@ function renderReviewChecklist(){
|
|
|
5426
5452
|
e.stopPropagation();
|
|
5427
5453
|
b.disabled = true;
|
|
5428
5454
|
b.textContent = '再検証中…';
|
|
5429
|
-
|
|
5455
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.goalreverify}/reverify`), { method: 'POST' }); }
|
|
5456
|
+
catch { showErr('再検証に失敗'); b.disabled = false; return; }
|
|
5430
5457
|
if (r.ok) await refresh(); else showErr('再検証に失敗');
|
|
5431
5458
|
};
|
|
5432
5459
|
}
|
|
@@ -5438,9 +5465,10 @@ function renderReviewChecklist(){
|
|
|
5438
5465
|
if (!text) return;
|
|
5439
5466
|
const t = reqs.find((r) => r.id === taskId);
|
|
5440
5467
|
b.disabled = true; input.disabled = true;
|
|
5441
|
-
|
|
5468
|
+
let r; try { r = await fetch(withKey(`/api/goals/${goalId}/reply`), { method: 'POST',
|
|
5442
5469
|
headers: { 'content-type': 'application/json' },
|
|
5443
|
-
body: JSON.stringify(replyWorkerPayload({ text: `[Fix request for requirement #${t?.num ?? ''} "${t?.title ?? ''}"] ${text}` })) });
|
|
5470
|
+
body: JSON.stringify(replyWorkerPayload({ text: `[Fix request for requirement #${t?.num ?? ''} "${t?.title ?? ''}"] ${text}` })) }); }
|
|
5471
|
+
catch { showErr('Failed to send the fix request.'); b.disabled = false; input.disabled = false; return; }
|
|
5444
5472
|
if (r.ok) { input.value = ''; input.placeholder = 'Sent — the worker will pick it up'; }
|
|
5445
5473
|
else showErr('Failed to send the fix request.');
|
|
5446
5474
|
b.disabled = false; input.disabled = false;
|
|
@@ -6308,7 +6336,16 @@ const FS_ICONS = {
|
|
|
6308
6336
|
// per-project count icons (HANDOFF-v45 §10): To Do = rounded square, Review = Scan
|
|
6309
6337
|
// (magnifier) — both stroked, NEUTRAL ink (never green); shape is the only distinction.
|
|
6310
6338
|
const FS_ICO_TD = '<svg class="pcg" viewBox="0 0 24 24"><rect x="5" y="5" width="14" height="14" rx="3.5"/></svg>';
|
|
6311
|
-
|
|
6339
|
+
// Eye, not magnifying glass or checkmark (Masa 2026-07-26): the magnifying-glass
|
|
6340
|
+
// read as "search". The first replacement — a square with a checkmark inside,
|
|
6341
|
+
// matching FS_ICO_TD's (To Do) frame — read as "done" instead ("あれ、これだと
|
|
6342
|
+
// めっちゃdoneっぽいわ"): a checkmark is universally a completion signal, and
|
|
6343
|
+
// pairing it with To Do's exact square made it worse, not better. An eye carries
|
|
6344
|
+
// neither "search" nor "done" — it says "look at this", which is what Review
|
|
6345
|
+
// actually is. Standard minimal eye glyph (outline + pupil), legible at the real
|
|
6346
|
+
// 11px render size (verified: a smaller pupil or added detail merges into a
|
|
6347
|
+
// blob at that size — this is the simplest shape that still reads as an eye).
|
|
6348
|
+
const FS_ICO_SCAN = '<svg class="pcg" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
|
|
6312
6349
|
// hover-only Reply / Edit / Delete on a To Do row (§1.5-10; flagship ACTS port)
|
|
6313
6350
|
function fsActs(goalId){
|
|
6314
6351
|
if (goalId == null || !state.goals.some((g) => g.id === goalId)) return '';
|
|
@@ -6352,7 +6389,7 @@ function renderFsRail(){
|
|
|
6352
6389
|
el.innerHTML = html;
|
|
6353
6390
|
for (const b of el.querySelectorAll('[data-fsproj]')) b.onclick = (e) => {
|
|
6354
6391
|
if (e.target.closest('.pname.editing') || b.querySelector('.pname.editing')) return;
|
|
6355
|
-
|
|
6392
|
+
setActiveProject(b.dataset.fsproj); document.body.classList.remove('projectsopen'); render();
|
|
6356
6393
|
};
|
|
6357
6394
|
const add = $('fsProjAdd'); if (add) add.onclick = createProject;
|
|
6358
6395
|
}
|
|
@@ -6523,11 +6560,6 @@ function renderFsFeed(){
|
|
|
6523
6560
|
let t = '';
|
|
6524
6561
|
if (turn.role === 'you') {
|
|
6525
6562
|
t += `<div class="cc-you"><span class="cy-n">You</span><div class="cy-t">${esc(turn.text)}</div></div>`;
|
|
6526
|
-
if (turn.markGoalId != null && !turn.undone) {
|
|
6527
|
-
const mg = state.goals.find((x) => x.id === turn.markGoalId);
|
|
6528
|
-
const n = mg ? goalReviewNumber({ goal: mg, tasks: state.tasks }) : turn.markGoalId;
|
|
6529
|
-
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>`;
|
|
6530
|
-
}
|
|
6531
6563
|
} else { // Claude Code reply — speaker line first (You → speaker → reply), then the text
|
|
6532
6564
|
const app = connectedAppMeta(turn.agent);
|
|
6533
6565
|
t += `<div class="cc-who">${app.icon}<span class="cs-n">${esc(app.label)}</span></div>`;
|
|
@@ -6580,24 +6612,12 @@ function renderFsFeed(){
|
|
|
6580
6612
|
// Delete a "needs your answer" card outright (restores 「Needs your answer」カードを削除
|
|
6581
6613
|
// できるように — a question you don't want to answer had no discard path in flagship).
|
|
6582
6614
|
for (const b of feed.querySelectorAll('[data-fsanswerdel]')) b.onclick = (e) => { e.stopPropagation(); fsTaskDelete(Number(b.dataset.fsanswerdel)); };
|
|
6583
|
-
// Pattern A (H23): Undo a "→ Added to To Do" marker — cancel the created goal
|
|
6584
|
-
// and drop the mark. A just-created (planning) goal is deletable; if it already
|
|
6585
|
-
// started the server returns non-OK (canDeleteGoal) and we say so quietly.
|
|
6586
|
-
for (const b of feed.querySelectorAll('[data-ccundo]')) b.onclick = async (e) => {
|
|
6587
|
-
e.stopPropagation();
|
|
6588
|
-
const gid = Number(b.dataset.ccundo);
|
|
6589
|
-
const r = await fetch(withKey(`/api/goals/${gid}`), { method: 'DELETE' });
|
|
6590
|
-
if (!r.ok) { showErr('Could not undo — the task may already be running'); return; }
|
|
6591
|
-
const turn = (state.chatTurns[state.active] || []).find((t) => t.markGoalId === gid);
|
|
6592
|
-
if (turn) turn.undone = true;
|
|
6593
|
-
state.goals = state.goals.filter((g) => g.id !== gid);
|
|
6594
|
-
render();
|
|
6595
|
-
};
|
|
6596
6615
|
}
|
|
6597
6616
|
async function fsAnswerQuestion(goalId, answer){
|
|
6598
6617
|
if (!answer) return;
|
|
6599
|
-
|
|
6600
|
-
headers: { 'content-type': 'application/json' }, body: JSON.stringify({ answer }) });
|
|
6618
|
+
let r; try { r = await fetch(withKey(`/api/goals/${goalId}/answer`), { method: 'POST',
|
|
6619
|
+
headers: { 'content-type': 'application/json' }, body: JSON.stringify({ answer }) }); }
|
|
6620
|
+
catch { showErr('Failed to send answer'); return; }
|
|
6601
6621
|
if (!r.ok) showErr('Failed to send answer'); else await refresh();
|
|
6602
6622
|
}
|
|
6603
6623
|
async function fsRetryTask(id){
|
|
@@ -6974,17 +6994,20 @@ function renderFsReview(){
|
|
|
6974
6994
|
// "Also carries" section (saItemHtml + the seeall handler).
|
|
6975
6995
|
for (const b of el.querySelectorAll('[data-fsapprove]')) b.onclick = async (e) => { e.stopPropagation();
|
|
6976
6996
|
let ok = true, mergeGoal = null;
|
|
6977
|
-
|
|
6978
|
-
const
|
|
6979
|
-
|
|
6980
|
-
|
|
6981
|
-
|
|
6997
|
+
try {
|
|
6998
|
+
for (const id of b.dataset.fsapprove.split(',')) {
|
|
6999
|
+
const r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
|
|
7000
|
+
if (!r.ok){ ok = false; break; }
|
|
7001
|
+
const j = await r.json(); if (j && j.needsMerge) mergeGoal = j.goal; // 07-20c: PR goal
|
|
7002
|
+
}
|
|
7003
|
+
} catch { ok = false; }
|
|
6982
7004
|
if (!ok){ showErr('Approve failed.'); return; }
|
|
6983
7005
|
await refresh();
|
|
6984
7006
|
if (mergeGoal) confirmMerge(mergeGoal); else celebrateApprove(); };
|
|
6985
7007
|
for (const b of el.querySelectorAll('[data-fsdismiss]')) b.onclick = async (e) => { e.stopPropagation();
|
|
6986
7008
|
// 07-20c: the review-card ✕ is a pure Reject → Rejected column (/reject), no auto-rework.
|
|
6987
|
-
let r; for (const id of b.dataset.fsdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/reject`), { method: 'POST' });
|
|
7009
|
+
let r; try { for (const id of b.dataset.fsdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/reject`), { method: 'POST' }); }
|
|
7010
|
+
catch { showErr('Dismiss failed.'); return; }
|
|
6988
7011
|
if (r && r.ok) await refresh(); else showErr('Dismiss failed.'); };
|
|
6989
7012
|
}
|
|
6990
7013
|
// Shared To Do bucketing (list rows AND the kanban board read the same groups —
|
|
@@ -7275,14 +7298,16 @@ function renderFsTodo(){
|
|
|
7275
7298
|
if (Number.isFinite(id)) fsOpenGoalDetail(id);
|
|
7276
7299
|
};
|
|
7277
7300
|
for (const b of el.querySelectorAll('[data-fsreopen]')) b.onclick = async (e) => { e.stopPropagation();
|
|
7278
|
-
|
|
7301
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsreopen}/reopen`), { method: 'POST' }); }
|
|
7302
|
+
catch { showErr('Reopen failed.'); return; }
|
|
7279
7303
|
if (!r.ok) showErr('Reopen failed.'); else await refresh(); };
|
|
7280
7304
|
for (const b of el.querySelectorAll('[data-fsrejdel]')) b.onclick = async (e) => { e.stopPropagation();
|
|
7281
7305
|
const preview = b.closest('.trow')?.querySelector('.tt')?.textContent || '';
|
|
7282
7306
|
// Delete on a rejected goal = revert: it closes the PR + drops the worktree, so a rejected
|
|
7283
7307
|
// goal that had a PR doesn't leave an orphan PR behind (DELETE would 409 — rejected isn't deletable).
|
|
7284
7308
|
if (!(await confirmDelete({ title: 'Discard this rejected goal?', preview }))) return;
|
|
7285
|
-
|
|
7309
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsrejdel}/revert`), { method: 'POST' }); }
|
|
7310
|
+
catch { showErr('Delete failed.'); return; }
|
|
7286
7311
|
if (!r.ok) showErr('Delete failed.'); else await refresh(); };
|
|
7287
7312
|
const more = el.querySelector('#fsTodoMore');
|
|
7288
7313
|
if (more) more.onclick = () => { fsUI.todoMore = !fsUI.todoMore; renderFsTodo(); };
|
|
@@ -7301,45 +7326,53 @@ function renderFsTodo(){
|
|
|
7301
7326
|
for (const b of el.querySelectorAll('[data-fsglog]')) b.onclick = () => toggleGoalDetail(Number(b.dataset.fsglog));
|
|
7302
7327
|
for (const b of el.querySelectorAll('[data-fsreverify]')) b.onclick = async () => {
|
|
7303
7328
|
b.disabled = true; b.textContent = 'Starting…';
|
|
7304
|
-
|
|
7329
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsreverify}/reverify`), { method: 'POST' }); }
|
|
7330
|
+
catch { showErr(document.documentElement.lang === 'ja' ? 'Retryに失敗しました' : 'Retry failed.'); b.disabled = false; b.textContent = 'Retry'; return; }
|
|
7305
7331
|
if (!r.ok) { showErr(document.documentElement.lang === 'ja' ? 'Retryに失敗しました' : 'Retry failed.'); b.disabled = false; b.textContent = 'Retry'; }
|
|
7306
7332
|
else await refresh();
|
|
7307
7333
|
};
|
|
7308
7334
|
for (const b of el.querySelectorAll('[data-fsarchive]')) b.onclick = async () => {
|
|
7309
|
-
|
|
7335
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsarchive}/archive`), { method: 'POST' }); }
|
|
7336
|
+
catch { showErr('Archive failed.'); return; }
|
|
7310
7337
|
if (!r.ok) showErr('Archive failed.'); else await refresh();
|
|
7311
7338
|
};
|
|
7312
7339
|
// LATER row actions (PRD §8.2): Start queues the shelved goal, Delete discards it.
|
|
7313
7340
|
for (const b of el.querySelectorAll('[data-fspstart]')) b.onclick = async (e) => { e.stopPropagation();
|
|
7314
|
-
|
|
7341
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fspstart}/activate`), { method: 'POST' }); }
|
|
7342
|
+
catch { showErr('Start failed.'); return; }
|
|
7315
7343
|
if (!r.ok) showErr('Start failed.'); else await refresh(); };
|
|
7316
7344
|
const startNextLater = el.querySelector('[data-fsstartnextlater]');
|
|
7317
7345
|
if (startNextLater) startNextLater.onclick = async (e) => { e.stopPropagation();
|
|
7318
7346
|
startNextLater.disabled = true;
|
|
7319
|
-
|
|
7347
|
+
let r; try { r = await fetch(withKey(`/api/goals/${startNextLater.dataset.fsstartnextlater}/activate`), { method: 'POST' }); }
|
|
7348
|
+
catch { showErr(document.documentElement.lang === 'ja' ? '開始できませんでした' : 'Could not start the next item.'); startNextLater.disabled = false; return; }
|
|
7320
7349
|
if (!r.ok) { showErr(document.documentElement.lang === 'ja' ? '開始できませんでした' : 'Could not start the next item.'); startNextLater.disabled = false; }
|
|
7321
7350
|
else await refresh(); };
|
|
7322
7351
|
for (const b of el.querySelectorAll('[data-fspdel]')) b.onclick = async (e) => { e.stopPropagation();
|
|
7323
7352
|
const preview = b.closest('.trow')?.querySelector('.tt')?.textContent || '';
|
|
7324
7353
|
if (!(await confirmDelete({ title: 'Delete this shelved goal?', preview }))) return;
|
|
7325
|
-
|
|
7354
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fspdel}`), { method: 'DELETE' }); }
|
|
7355
|
+
catch { showErr('Delete failed.'); return; }
|
|
7326
7356
|
if (!r.ok) showErr('Delete failed.'); else await refresh(); };
|
|
7327
7357
|
// hover-toolbar actions → same endpoints as the SL1 goalhead toolbar
|
|
7328
7358
|
for (const b of el.querySelectorAll('[data-fsreply]')) b.onclick = (e) => { e.stopPropagation();
|
|
7329
7359
|
fsInlineInput(b.closest('.trow'), '', 'Reply to this goal…', async (text) => {
|
|
7330
|
-
|
|
7360
|
+
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 })) }); }
|
|
7361
|
+
catch { showErr('未開始のゴールは編集を使ってください'); return; }
|
|
7331
7362
|
if (!r.ok) showErr('未開始のゴールは編集を使ってください'); await refresh();
|
|
7332
7363
|
}); };
|
|
7333
7364
|
for (const b of el.querySelectorAll('[data-fsedit]')) b.onclick = (e) => { e.stopPropagation();
|
|
7334
7365
|
const g = state.goals.find((x) => x.id === Number(b.dataset.fsedit));
|
|
7335
7366
|
fsInlineInput(b.closest('.trow'), g?.text ?? '', 'Edit the goal…', async (text) => {
|
|
7336
|
-
|
|
7367
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsedit}`), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }) }); }
|
|
7368
|
+
catch { showErr('Cannot edit a running/finished goal — reply to append instead'); return; }
|
|
7337
7369
|
if (!r.ok) showErr('Cannot edit a running/finished goal — reply to append instead'); await refresh();
|
|
7338
7370
|
}); };
|
|
7339
7371
|
for (const b of el.querySelectorAll('[data-fsdel]')) b.onclick = async (e) => { e.stopPropagation();
|
|
7340
7372
|
const preview = b.closest('.trow')?.querySelector('.tt')?.textContent || '';
|
|
7341
7373
|
if (!(await confirmDelete({ title: 'Delete this goal?', preview }))) return;
|
|
7342
|
-
|
|
7374
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsdel}`), { method: 'DELETE' }); }
|
|
7375
|
+
catch { showErr('Cannot delete a running/finished goal — reply to append instead'); return; }
|
|
7343
7376
|
if (!r.ok) showErr('Cannot delete a running/finished goal — reply to append instead'); else await refresh(); };
|
|
7344
7377
|
}
|
|
7345
7378
|
async function fsCreateGoal(text, pending){
|
|
@@ -7457,7 +7490,7 @@ async function createProject(){
|
|
|
7457
7490
|
if (!r.ok) throw new Error();
|
|
7458
7491
|
const body = await r.json();
|
|
7459
7492
|
state.projects = body.projects ?? [...state.projects, body.project].filter(Boolean);
|
|
7460
|
-
|
|
7493
|
+
setActiveProject(body.project.id);
|
|
7461
7494
|
document.body.classList.remove('projectsopen');
|
|
7462
7495
|
render();
|
|
7463
7496
|
} catch { showErr('New project failed.'); }
|
|
@@ -7489,7 +7522,7 @@ async function deleteProject(p){
|
|
|
7489
7522
|
const body = await r.json().catch(() => ({}));
|
|
7490
7523
|
if (!r.ok) throw new Error(body.error || 'Delete project failed.');
|
|
7491
7524
|
state.projects = body.projects ?? state.projects.filter((x) => x.id !== p.id);
|
|
7492
|
-
if (state.active === p.id) state.
|
|
7525
|
+
if (state.active === p.id) setActiveProject(state.projects[0]?.id ?? state.active);
|
|
7493
7526
|
document.body.classList.remove('projectsopen');
|
|
7494
7527
|
render();
|
|
7495
7528
|
} catch (err) {
|
|
@@ -7524,21 +7557,24 @@ $('fsProjects').addEventListener('dblclick', (e) => {
|
|
|
7524
7557
|
async function fsTaskRename(span, goalId){
|
|
7525
7558
|
if (!goalId) return;
|
|
7526
7559
|
inlineFsRename(span, async (v) => {
|
|
7527
|
-
|
|
7560
|
+
let r; try { r = await fetch(withKey(`/api/goals/${goalId}`), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: v }) }); }
|
|
7561
|
+
catch { showErr('Cannot rename a running/finished goal — reply to append instead'); return; }
|
|
7528
7562
|
if (!r.ok) showErr('Cannot rename a running/finished goal — reply to append instead');
|
|
7529
7563
|
await refresh();
|
|
7530
7564
|
});
|
|
7531
7565
|
}
|
|
7532
7566
|
async function fsTaskArchive(goalId){
|
|
7533
7567
|
if (!goalId) return;
|
|
7534
|
-
|
|
7568
|
+
let r; try { r = await fetch(withKey(`/api/goals/${goalId}/archive`), { method: 'POST' }); }
|
|
7569
|
+
catch { showErr('Archive failed.'); return; }
|
|
7535
7570
|
if (!r.ok) showErr('Archive failed.'); else await refresh();
|
|
7536
7571
|
}
|
|
7537
7572
|
async function fsTaskDelete(goalId){
|
|
7538
7573
|
if (!goalId) return;
|
|
7539
7574
|
const preview = state.goals.find((x) => x.id === goalId)?.text || '';
|
|
7540
7575
|
if (!(await confirmDelete({ title: 'Delete this goal?', preview }))) return;
|
|
7541
|
-
|
|
7576
|
+
let r; try { r = await fetch(withKey(`/api/goals/${goalId}`), { method: 'DELETE' }); }
|
|
7577
|
+
catch { showErr('Cannot delete a running/finished goal — reply to append instead'); return; }
|
|
7542
7578
|
if (!r.ok) showErr('Cannot delete a running/finished goal — reply to append instead'); else await refresh();
|
|
7543
7579
|
}
|
|
7544
7580
|
$('fsTodoSec').addEventListener('contextmenu', (e) => {
|
|
@@ -7572,7 +7608,7 @@ function fsSwitcherOpen(){
|
|
|
7572
7608
|
function fsSwitcherHi(){ [...fsCtx.querySelectorAll('button')].forEach((b, i) => b.classList.toggle('hi', i === fsCtxSel)); }
|
|
7573
7609
|
function fsSelectProjByIndex(idx){
|
|
7574
7610
|
const rows = [...document.querySelectorAll('#fsProjects .fsproj')]; if (idx < 0 || idx >= rows.length) return;
|
|
7575
|
-
|
|
7611
|
+
setActiveProject(rows[idx].dataset.fsproj); render();
|
|
7576
7612
|
}
|
|
7577
7613
|
document.addEventListener('keydown', (e) => {
|
|
7578
7614
|
if (fsCtx.classList.contains('open') && fsCtxSwitcher) {
|
|
@@ -8312,7 +8348,7 @@ async function refresh(){
|
|
|
8312
8348
|
state.workflowColumns = s.workflowColumns ?? {};
|
|
8313
8349
|
state.reviewDefinitions = s.reviewDefinitions ?? {};
|
|
8314
8350
|
checkUiVersion(s.uiVersion);
|
|
8315
|
-
if (!state.projects.some((p) => p.id === state.active)) state.
|
|
8351
|
+
if (!state.projects.some((p) => p.id === state.active)) setActiveProject(state.projects[0]?.id);
|
|
8316
8352
|
render();
|
|
8317
8353
|
} catch { /* server briefly down; SSE reconnect will retry */ }
|
|
8318
8354
|
}
|
|
@@ -8397,7 +8433,7 @@ function connectSSE(){
|
|
|
8397
8433
|
else if (m.ev === 'review-definition') state.reviewDefinitions[m.projectId] = m.reviewDefinition;
|
|
8398
8434
|
else if (m.ev === 'projects') {
|
|
8399
8435
|
state.projects = m.projects ?? state.projects;
|
|
8400
|
-
if (!state.projects.some((p) => p.id === state.active)) state.
|
|
8436
|
+
if (!state.projects.some((p) => p.id === state.active)) setActiveProject(state.projects[0]?.id);
|
|
8401
8437
|
}
|
|
8402
8438
|
else if (m.ev === 'ping') { checkUiVersion(m.uiVersion); return; }
|
|
8403
8439
|
else if (m.ev === 'auth') { renderAuthBanner(m.auth); return; } // selected worker's sign-in preflight result
|
|
@@ -8433,7 +8469,7 @@ function bootDisconnected(info){
|
|
|
8433
8469
|
applyTheme(); applyCust();
|
|
8434
8470
|
// seed a minimal board so it renders like the real empty state
|
|
8435
8471
|
state.projects = [{ id: '__onboard', name: 'Your project' }];
|
|
8436
|
-
|
|
8472
|
+
setActiveProject('__onboard');
|
|
8437
8473
|
state.billing = { licensed: false, email };
|
|
8438
8474
|
try { render(); } catch (e) { console.warn('disconnected render', e); }
|
|
8439
8475
|
const heroSpan = document.querySelector('#fsClawdHero .hstat');
|
|
@@ -8649,15 +8685,6 @@ async function postOutboxItem(item){
|
|
|
8649
8685
|
outbox.land(item.oid, goal.id);
|
|
8650
8686
|
upsert(state.goals, goal);
|
|
8651
8687
|
if (recentlySignedOut && !state.billing?.licensed) { recentlySignedOut = false; showSignInNudge(); }
|
|
8652
|
-
// Pattern A (H23): this send became a real To-Do — mark the most recent
|
|
8653
|
-
// unmarked user turn "→ Added to To Do #NN" in the feed. Skip a needsInput
|
|
8654
|
-
// goal (it's still asking; shown as the question card at the feed bottom).
|
|
8655
|
-
if (goal.status !== 'needsInput') {
|
|
8656
|
-
const turns = state.chatTurns[item.projectId] || [];
|
|
8657
|
-
for (let k = turns.length - 1; k >= 0; k--) {
|
|
8658
|
-
if (turns[k].role === 'you' && turns[k].markGoalId == null) { turns[k].markGoalId = goal.id; break; }
|
|
8659
|
-
}
|
|
8660
|
-
}
|
|
8661
8688
|
} catch {
|
|
8662
8689
|
outbox.mark(item.oid, 'unsent');
|
|
8663
8690
|
} finally {
|
|
@@ -8872,16 +8899,13 @@ async function send(){
|
|
|
8872
8899
|
projectId: state.active, text, pr: 'auto', model, effort, agent: state.connectedApp,
|
|
8873
8900
|
mode, skill: skill || null,
|
|
8874
8901
|
workspaceMode: workspaceMode(),
|
|
8875
|
-
// Keep the old wire contract too: only direct sends worktree:false.
|
|
8876
|
-
worktree: worktreeOn() ? undefined : false,
|
|
8877
8902
|
pending: later, review,
|
|
8878
8903
|
images: attach.map((a) => a.path), imageUrls: attach.map((a) => a.url),
|
|
8879
8904
|
status: 'sending', ts: Date.now(),
|
|
8880
8905
|
};
|
|
8881
8906
|
outbox.add(item); // saved locally BEFORE any network call
|
|
8882
8907
|
// Pattern A (H23): the user's own turn enters the center feed immediately, so
|
|
8883
|
-
// the composer reads like a conversation
|
|
8884
|
-
// .cc-mark is attached to this turn later; if it's chat, a reply turn follows.
|
|
8908
|
+
// the composer reads like a conversation; if it's chat, a reply turn follows.
|
|
8885
8909
|
(state.chatTurns[state.active] ||= []).push({ role: 'you', text, ts: Date.now() });
|
|
8886
8910
|
state.attach = [];
|
|
8887
8911
|
// mode/skill/later/review are per-message → clear after send (model stays sticky).
|
|
@@ -9010,9 +9034,12 @@ function renderSummaryPera(){
|
|
|
9010
9034
|
body.querySelector('[data-peradetail]') && (body.querySelector('[data-peradetail]').onclick = () => { state.peraDetailOpen = !state.peraDetailOpen; renderSummaryPera(); });
|
|
9011
9035
|
}
|
|
9012
9036
|
async function peraAct(ids, kind){
|
|
9013
|
-
|
|
9014
|
-
|
|
9015
|
-
|
|
9037
|
+
try {
|
|
9038
|
+
for (const id of String(ids).split(',')) {
|
|
9039
|
+
const r = await fetch(withKey(`/api/goals/${id}/${kind}`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: kind === 'dismiss' ? JSON.stringify({ text: '' }) : undefined });
|
|
9040
|
+
if (!r.ok) throw new Error();
|
|
9041
|
+
}
|
|
9042
|
+
} catch { showErr(kind === 'approve' ? 'Approve failed.' : 'Dismiss failed.'); return; }
|
|
9016
9043
|
if (kind === 'approve') celebrateApprove(); // confetti from the ペライチ too
|
|
9017
9044
|
state.peraSpot = 0;
|
|
9018
9045
|
await refresh();
|
|
@@ -9130,6 +9157,7 @@ function saRowFromDigest(r, gs, ts){
|
|
|
9130
9157
|
goalText: String(g?.text ?? '').replace(/\s+/g, ' ').trim(),
|
|
9131
9158
|
did, siblings, risk: g?.risk ?? null,
|
|
9132
9159
|
verificationInfraError: g?.verificationInfraError ?? r.verificationInfraError ?? null,
|
|
9160
|
+
prNote: g?.prNote ?? null,
|
|
9133
9161
|
note: g?.retest?.passed ? g.retest.note : null,
|
|
9134
9162
|
pr: r.pr,
|
|
9135
9163
|
// P0b (Masa 2026-07-21): the AI's own TodoWrite breakdown for the "Steps" section
|
|
@@ -9177,6 +9205,15 @@ function saRowFromDigest(r, gs, ts){
|
|
|
9177
9205
|
run: (() => {
|
|
9178
9206
|
return latestGoalRunForOutcome(g?.id, ts, outcome);
|
|
9179
9207
|
})(),
|
|
9208
|
+
// The worker DID try to declare something but it came out unusable (goal #733,
|
|
9209
|
+
// 2026-07-26: a Write followed by an Edit broke the JSON mid-edit; Review showed
|
|
9210
|
+
// no "Open it" section and no explanation, indistinguishable from "nothing to
|
|
9211
|
+
// run"). engine/server.mjs's readRunDeclaration sets task.runDeclError in that
|
|
9212
|
+
// case; only shown when there's no successful `run` to display instead.
|
|
9213
|
+
runDeclError: (() => {
|
|
9214
|
+
const t = ts.find((x) => r.goalIds.includes(x.goalId) && x.runDeclError);
|
|
9215
|
+
return t?.runDeclError ?? null;
|
|
9216
|
+
})(),
|
|
9180
9217
|
// Explicit review-summary display fields (spec §11: the card's shape follows the
|
|
9181
9218
|
// task). A goal's reviewSummary may carry them directly (e.g. a conversational task
|
|
9182
9219
|
// that reports an Answer + its Activity, or a ticket whose test count / diff totals
|
|
@@ -9569,7 +9606,8 @@ function saItemHtml(it, i){
|
|
|
9569
9606
|
const fDel = it.metaDel ?? (hasGitDiff ? `−${d.del}` : clientDiff ? `−${cDel}` : null);
|
|
9570
9607
|
const chips = [];
|
|
9571
9608
|
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>`);
|
|
9572
|
-
|
|
9609
|
+
const tokUntied = it.tokenOpt?.basis === 'baseline-untied';
|
|
9610
|
+
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>`);
|
|
9573
9611
|
if (testsLabel) chips.push(`<span class="mx"><span class="mk">Tests</span> <b class="ok">${esc(testsLabel)}</b></span>`);
|
|
9574
9612
|
// Files: hide-empty — only with changed files AND totals (a bare-count chip reads odd).
|
|
9575
9613
|
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>`);
|
|
@@ -9585,7 +9623,7 @@ function saItemHtml(it, i){
|
|
|
9585
9623
|
if (it.recording) chips.push(it.recordingUrl
|
|
9586
9624
|
? `<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>`
|
|
9587
9625
|
: `<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>`);
|
|
9588
|
-
const tokCmp = it.tokenOpt?.basis === 'baseline' ? 'vs your recent average' : 'from prompt caching';
|
|
9626
|
+
const tokCmp = tokUntied ? 'vs your recent average (no technique behind it)' : it.tokenOpt?.basis === 'baseline' ? 'vs your recent average' : 'from prompt caching';
|
|
9589
9627
|
const tokTech = (it.tokenOpt?.techniques ?? []).slice(0, 4).join(' / ');
|
|
9590
9628
|
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>` : '';
|
|
9591
9629
|
// §1.5-3: with Open it moved into the body, a card with no PR / tokens / tests has no
|
|
@@ -9603,11 +9641,20 @@ function saItemHtml(it, i){
|
|
|
9603
9641
|
// failure reason land after the press. Nothing declared → no section (§1.5-3). ──
|
|
9604
9642
|
// it.run?.compare/title alone (no cmd/url) has nothing a press can start — a
|
|
9605
9643
|
// compare-only or title-only declaration must not draw the "Start it" button.
|
|
9644
|
+
// it.runDeclError (2026-07-26, goal #733): the worker DID try to declare an
|
|
9645
|
+
// artifact/command, but .manager-run.json came out unusable (e.g. a Write then
|
|
9646
|
+
// an Edit broke the JSON mid-edit) — this must not look identical to "nothing
|
|
9647
|
+
// to run" (§1.5-3 no empty box still applies: don't invent a fake Open it, but
|
|
9648
|
+
// don't stay silent about a real attempt either). Same honest-advisory shape as
|
|
9649
|
+
// prRepairBody below. A reply is the repair path: the worker rewrites the file.
|
|
9650
|
+
const runDeclErrorBody = (!((it.run?.cmd || it.run?.url)) && it.runDeclError)
|
|
9651
|
+
? `<div class="ropen rdeclerr"><p class="rbody v">${failureLang() === 'ja' ? 'AIが開けるものを用意しようとしましたが、記録が読み取れませんでした。返信して直させてください。' : 'The AI tried to declare something to open here, but the record was unreadable. Reply to have it rewritten.'}</p></div>`
|
|
9652
|
+
: '';
|
|
9606
9653
|
const openBody = (it.run?.cmd || it.run?.url)
|
|
9607
9654
|
? `<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>`
|
|
9608
9655
|
: it.pr
|
|
9609
9656
|
? `<div class="ropen"><a class="olink" href="${esc(it.pr)}" target="_blank" rel="noopener">${esc(it.pr)}<span class="ar">↗</span></a><p class="rbody v">Review and merge this pull request to finish the item.</p></div>`
|
|
9610
|
-
:
|
|
9657
|
+
: runDeclErrorBody;
|
|
9611
9658
|
const prRepairBody = it.goal?.prError
|
|
9612
9659
|
? `<div class="prfail"><b>${esc(it.goal.prRepair?.title || 'PR blocked')}</b> <span class="dim">${esc(it.goal.prError)}</span>${it.goal.prRepair?.steps?.length ? `<ul>${it.goal.prRepair.steps.map((s) => `<li>${esc(s)}</li>`).join('')}</ul>` : ''}${it.goal.prRepair?.dirtyFiles?.length ? `<div class="dim">dirty: ${it.goal.prRepair.dirtyFiles.map(esc).join(' · ')}</div>` : ''}<div class="acts2"><button class="errbtn" data-saact="retrypr:${i}">${failureLang() === 'ja' ? 'PR再試行' : 'Retry PR'}</button><button class="errbtn" data-saact="glog:${i}">${failureLang() === 'ja' ? 'ログ確認' : 'View log'}</button></div></div>`
|
|
9613
9660
|
: '';
|
|
@@ -9693,6 +9740,15 @@ function saItemHtml(it, i){
|
|
|
9693
9740
|
const riskHtml = risk?.shouldPause
|
|
9694
9741
|
? `<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>`
|
|
9695
9742
|
: '';
|
|
9743
|
+
// ── PR-requested-but-no-code note (goal #751, Masa 2026-07-26): a wantsPR goal whose
|
|
9744
|
+
// worker ended with zero changed files and no proof — most often a lane-conflict
|
|
9745
|
+
// report ("空き次第自動再開します") that could not actually resume, since a `claude -p`
|
|
9746
|
+
// run is one-shot. Advisory only (isNothingVerifiableForPR never blocks — Failedを作ら
|
|
9747
|
+
// ない方針を尊重), so the reviewer sees it and decides instead of it silently reading
|
|
9748
|
+
// as a normal completed PR review. ──
|
|
9749
|
+
const prNoteHtml = it.prNote
|
|
9750
|
+
? `<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>`
|
|
9751
|
+
: '';
|
|
9696
9752
|
// ── Activity (spec §3): ONE Linear-docs pulldown. The "Activity" heading is plain; the
|
|
9697
9753
|
// chevron sits on the FIRST line (the opening "You …" row); tapping it opens the rest
|
|
9698
9754
|
// below, offset-indented. The review Discussion folds in at the bottom of the body. The
|
|
@@ -9784,6 +9840,7 @@ function saItemHtml(it, i){
|
|
|
9784
9840
|
${S('Proof', proofInner)}
|
|
9785
9841
|
${S('What changed', changedBody)}
|
|
9786
9842
|
${riskHtml}
|
|
9843
|
+
${prNoteHtml}
|
|
9787
9844
|
${conversationSec}
|
|
9788
9845
|
${activitySec}
|
|
9789
9846
|
${S('Workflow', saWorkflowHtml(it))}
|
|
@@ -10039,13 +10096,32 @@ function saMoveFeedbackOutOfReview(it, body){
|
|
|
10039
10096
|
if (task?.id) upsert(state.tasks, task);
|
|
10040
10097
|
const at = SA.items.findIndex((row) => row.goalId === it.goalId);
|
|
10041
10098
|
const next = at >= 0 ? (SA.items[at + 1] || SA.items[at - 1]) : null;
|
|
10099
|
+
// Was the reviewer actually looking at the card that just left Review (vs having already
|
|
10100
|
+
// scrolled past it to read another one)? If it's fully above the visible scroll area, the
|
|
10101
|
+
// reviewer is reading something else — snapping the view to the next card's top would yank
|
|
10102
|
+
// it out from under them (Masa 2026-07-26: replying to the card above and having the one
|
|
10103
|
+
// you're reading jump to the top was reported as stressful).
|
|
10104
|
+
const bodyEl = document.querySelector('#seeall .sa-body');
|
|
10105
|
+
const cardEl = saCardEl(it.goalId);
|
|
10106
|
+
const priorScrollTop = bodyEl?.scrollTop ?? 0;
|
|
10107
|
+
const removedHeight = cardEl ? cardEl.getBoundingClientRect().height : 0;
|
|
10108
|
+
const cardAboveViewport = !!(cardEl && bodyEl && cardEl.getBoundingClientRect().bottom <= bodyEl.getBoundingClientRect().top + 1);
|
|
10042
10109
|
if (at >= 0) SA.items.splice(at, 1);
|
|
10043
10110
|
render();
|
|
10044
10111
|
if (SA.solo === it.goalId || !SA.items.length) { saRender(); saClose(); return; }
|
|
10045
10112
|
SA.cur = next?.goalId ?? SA.items[0]?.goalId ?? null;
|
|
10046
|
-
|
|
10047
|
-
|
|
10048
|
-
|
|
10113
|
+
if (cardAboveViewport) {
|
|
10114
|
+
// Keep reading where we were: compensate for the removed card's height instead of
|
|
10115
|
+
// forcing a scroll-to-top of whatever is now "current".
|
|
10116
|
+
SA.focusGid = null;
|
|
10117
|
+
saRender();
|
|
10118
|
+
const newBody = document.querySelector('#seeall .sa-body');
|
|
10119
|
+
if (newBody) newBody.scrollTop = Math.max(0, priorScrollTop - removedHeight);
|
|
10120
|
+
} else {
|
|
10121
|
+
SA.focusGid = SA.cur;
|
|
10122
|
+
SA.focusScrolled = false;
|
|
10123
|
+
saRender();
|
|
10124
|
+
}
|
|
10049
10125
|
}
|
|
10050
10126
|
function saCardEl(goalId){ return document.querySelector(`#seeall .sa-item[data-gid="${goalId}"]`); }
|
|
10051
10127
|
// Flip one card to its "Working on it" head without redrawing anything else (決定2).
|
|
@@ -10229,7 +10305,8 @@ $('seeall').addEventListener('click', async (e) => {
|
|
|
10229
10305
|
// SA.items[i] lookup below.
|
|
10230
10306
|
if (k === 'foldopen') return openReviewChecklist(Number(v));
|
|
10231
10307
|
if (k === 'foldundo') {
|
|
10232
|
-
|
|
10308
|
+
let r; try { r = await fetch(withKey(`/api/goals/${v}/unfold`), { method: 'POST' }); }
|
|
10309
|
+
catch { showErr('Undo failed.'); return; }
|
|
10233
10310
|
if (r && r.ok) await refresh(); else showErr('Undo failed.');
|
|
10234
10311
|
return;
|
|
10235
10312
|
}
|
|
@@ -10242,7 +10319,8 @@ $('seeall').addEventListener('click', async (e) => {
|
|
|
10242
10319
|
if (k === 'reverify') {
|
|
10243
10320
|
b.disabled = true;
|
|
10244
10321
|
b.textContent = '再検証中…';
|
|
10245
|
-
|
|
10322
|
+
let r; try { r = await fetch(withKey(`/api/goals/${it.goalId}/reverify`), { method: 'POST' }); }
|
|
10323
|
+
catch { showErr('再検証に失敗'); b.disabled = false; return; }
|
|
10246
10324
|
if (r.ok) await refresh(); else showErr('再検証に失敗');
|
|
10247
10325
|
return;
|
|
10248
10326
|
}
|
|
@@ -10427,7 +10505,6 @@ function renderCtxBar(){
|
|
|
10427
10505
|
const grp = $('ctxbrgrp');
|
|
10428
10506
|
if (grp) grp.style.display = branch ? '' : 'none';
|
|
10429
10507
|
const bn = $('ctxbranchname'); if (bn) bn.textContent = branch;
|
|
10430
|
-
renderWorktreeChip();
|
|
10431
10508
|
const menu = $('ctxdirmenu'); if (!menu) return;
|
|
10432
10509
|
const cur = dir;
|
|
10433
10510
|
const opts = (state.repos ?? [])
|
|
@@ -10439,31 +10516,12 @@ function renderCtxBar(){
|
|
|
10439
10516
|
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>`;
|
|
10440
10517
|
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>`;
|
|
10441
10518
|
}
|
|
10442
|
-
// Workspace mode
|
|
10443
|
-
//
|
|
10444
|
-
// a
|
|
10445
|
-
function
|
|
10446
|
-
function
|
|
10447
|
-
function
|
|
10448
|
-
function goalWorkspaceMode(g){ return (g?.workspaceMode === 'direct-workspace' || g?.worktree === false) ? 'direct-workspace' : 'isolated-worktree'; }
|
|
10449
|
-
function workspaceModeText(g){ return goalWorkspaceMode(g) === 'direct-workspace' ? 'direct workspace' : 'isolated worktree'; }
|
|
10450
|
-
function renderWorktreeChip(){
|
|
10451
|
-
const el = $('ctxwt'); if (!el) return;
|
|
10452
|
-
const isolated = worktreeOn();
|
|
10453
|
-
const proj = state.projects.find((p) => p.id === state.active);
|
|
10454
|
-
el.setAttribute('aria-checked', isolated ? 'true' : 'false');
|
|
10455
|
-
const label = $('ctxwtlabel');
|
|
10456
|
-
if (label) label.textContent = isolated ? 'isolated worktree' : 'direct workspace';
|
|
10457
|
-
el.title = isolated
|
|
10458
|
-
? 'isolated worktree: default clean copy; this folder is not edited.'
|
|
10459
|
-
: 'direct workspace: inherits the current state and edits this folder.';
|
|
10460
|
-
const warn = $('ctxworkwarn');
|
|
10461
|
-
if (warn) {
|
|
10462
|
-
const dirty = !isolated && !!proj?.dirty;
|
|
10463
|
-
warn.hidden = !dirty;
|
|
10464
|
-
warn.textContent = dirty ? `Dirty workspace warning: direct workspace will edit ${folderName(proj?.dir) || 'this folder'} as it is now.` : '';
|
|
10465
|
-
}
|
|
10466
|
-
}
|
|
10519
|
+
// Workspace mode is fixed to the isolated worktree (clean separate checkout). The
|
|
10520
|
+
// direct-workspace escape hatch was removed after two accidents where a stray click
|
|
10521
|
+
// edited a shared checkout directly; there was no real upside to keep it around for.
|
|
10522
|
+
function workspaceMode(){ return 'isolated-worktree'; }
|
|
10523
|
+
function goalWorkspaceMode(){ return 'isolated-worktree'; }
|
|
10524
|
+
function workspaceModeText(){ return 'isolated worktree'; }
|
|
10467
10525
|
// Asked for once, and only when the menu is first opened — the same "only when you look
|
|
10468
10526
|
// at it" rule the implementation log and the conversation follow.
|
|
10469
10527
|
async function loadRepos(){
|
|
@@ -10524,11 +10582,6 @@ $('ctxdirbtn').onclick = (e) => {
|
|
|
10524
10582
|
menu.classList.toggle('show');
|
|
10525
10583
|
if (opening) { renderCtxBar(); loadRepos(); } // no-op once boot's fetch has landed
|
|
10526
10584
|
};
|
|
10527
|
-
$('ctxwt').onclick = (e) => {
|
|
10528
|
-
e.stopPropagation();
|
|
10529
|
-
localStorage.setItem(worktreeKey(), worktreeOn() ? '0' : '1');
|
|
10530
|
-
renderWorktreeChip();
|
|
10531
|
-
};
|
|
10532
10585
|
$('ctxdirmenu').addEventListener('click', (e) => {
|
|
10533
10586
|
e.stopPropagation();
|
|
10534
10587
|
if (e.target.closest('#ctxopenfolder')) { openFolderNative(); return; }
|
|
@@ -11677,6 +11730,7 @@ function buildGearPop(){
|
|
|
11677
11730
|
if (!(await confirmDelete({ title: 'Log out of Google?', body: 'You can sign back in anytime.', confirmLabel: 'Log out', confirmDanger: false }))) return;
|
|
11678
11731
|
signOutBtn.disabled = true;
|
|
11679
11732
|
try { await fetch(withKey('/api/signout'), { method: 'POST' }); recentlySignedOut = true; }
|
|
11733
|
+
catch {}
|
|
11680
11734
|
finally { await refresh(); if ($('gearPop')) buildGearPop(); }
|
|
11681
11735
|
};
|
|
11682
11736
|
const rulesBtn = pop.querySelector('[data-grules]');
|