@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 +232 -196
- package/engine/lib.mjs +133 -8
- package/engine/server.mjs +85 -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
|
|
@@ -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:
|
|
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
|
|
3905
|
-
//
|
|
3906
|
-
//
|
|
3907
|
-
//
|
|
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
|
-
|
|
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 = () => {
|
|
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"
|
|
4109
|
-
<button type="button" class="qbtn no" data-qdismiss="${ids}" title="Send back"
|
|
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
|
-
|
|
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
|
-
|
|
4502
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
4693
|
-
<button type="button" class="qbtn no" data-qdismiss="${g.id}" title="Send back"
|
|
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
|
-
|
|
4728
|
-
|
|
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
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
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
|
-
|
|
4780
|
-
const
|
|
4781
|
-
|
|
4782
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4994
|
-
|
|
4995
|
-
|
|
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
|
-
|
|
5192
|
-
|
|
5193
|
-
|
|
5194
|
-
|
|
5195
|
-
|
|
5196
|
-
|
|
5197
|
-
|
|
5198
|
-
|
|
5199
|
-
|
|
5200
|
-
|
|
5201
|
-
|
|
5202
|
-
|
|
5203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6993
|
-
const
|
|
6994
|
-
|
|
6995
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
9029
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10080
|
-
|
|
10081
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
10476
|
-
//
|
|
10477
|
-
// a
|
|
10478
|
-
function
|
|
10479
|
-
function
|
|
10480
|
-
function
|
|
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]');
|