@galda/cli 0.10.83 → 0.10.85
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/CLAUDE.md +16 -0
- package/app/index.html +293 -91
- package/app/theme.css +6 -6
- package/engine/lib.mjs +127 -5
- package/engine/mcp.mjs +33 -1
- package/engine/server.mjs +174 -17
- package/package.json +1 -1
package/app/index.html
CHANGED
|
@@ -4,6 +4,24 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>Galda</title>
|
|
7
|
+
<script>
|
|
8
|
+
// app/index.html is a server-backed SPA, not a standalone artifact. Rendering the
|
|
9
|
+
// product shell from file:// makes missing theme/API/SSE data look like an old UI.
|
|
10
|
+
if (location.protocol === 'file:') {
|
|
11
|
+
document.documentElement.classList.add('galda-invalid-file-preview');
|
|
12
|
+
document.title = 'Galda — invalid preview URL';
|
|
13
|
+
}
|
|
14
|
+
</script>
|
|
15
|
+
<style>
|
|
16
|
+
#galdaInvalidFilePreview{display:none}
|
|
17
|
+
html.galda-invalid-file-preview body{margin:0;background:#0a0a0b;color:#e9e7e2;font-family:system-ui,sans-serif}
|
|
18
|
+
html.galda-invalid-file-preview body>*{display:none!important}
|
|
19
|
+
html.galda-invalid-file-preview body>#galdaInvalidFilePreview{display:grid!important;min-height:100vh;place-items:center;padding:32px}
|
|
20
|
+
.galda-invalid-file-card{max-width:620px;border:1px solid rgba(255,255,255,.12);border-radius:16px;padding:28px;background:#101014}
|
|
21
|
+
.galda-invalid-file-card h1{margin:0 0 12px;font-size:22px;letter-spacing:-.02em}
|
|
22
|
+
.galda-invalid-file-card p{margin:8px 0;color:#a3a097;line-height:1.6}
|
|
23
|
+
.galda-invalid-file-card code{color:#e9e7e2;font-family:ui-monospace,monospace}
|
|
24
|
+
</style>
|
|
7
25
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%233B6BFF'/%3E%3Cstop offset='1' stop-color='%230E2AA8'/%3E%3C/linearGradient%3E%3C/defs%3E%3Ccircle cx='16' cy='16' r='11' fill='none' stroke='url(%23g)' stroke-width='5'/%3E%3C/svg%3E">
|
|
8
26
|
<!-- brand fonts FINAL (v45 §1): the 3 adopted faces self-host in /fonts.css — the old
|
|
9
27
|
Google Fonts Inter/JetBrains request was removed (offline-safe, no stray families) -->
|
|
@@ -463,6 +481,11 @@
|
|
|
463
481
|
.qansrow{display:flex;gap:7px;align-items:center;margin-top:8px}
|
|
464
482
|
.qansrow input{flex:1;background:var(--glass);border:1px solid var(--hair2);border-radius:8px;color:var(--ink);font-size:12.5px;padding:6px 10px;outline:none}
|
|
465
483
|
.qsub{font-size:10.5px;color:var(--ink3);margin-top:7px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
484
|
+
.approvalflow .qscope{margin-top:7px;font-size:11px;color:var(--ink2);line-height:1.45;white-space:pre-wrap}
|
|
485
|
+
.approvalflow .qactions{display:flex;gap:6px;margin-top:10px;flex-wrap:wrap}
|
|
486
|
+
.approvalflow .qaction{border:1px solid rgba(var(--blue-rgb),.5);border-radius:8px;padding:6px 12px;background:transparent;color:var(--blue);font-size:11.5px;cursor:pointer}
|
|
487
|
+
.approvalflow .qaction.primary{background:var(--blue);color:white;border-color:var(--blue)}
|
|
488
|
+
.approvalflow .qaction.cancel{color:var(--ink3);border-color:var(--hair2)}
|
|
466
489
|
/* The one line under a reply box: what the AI asked back, or the answer it gave.
|
|
467
490
|
Same part as .qsub, but an answer is a sentence — it must wrap, not ellipsis. */
|
|
468
491
|
/* The reply box's own short thread (CDO HANDOFF-REPLY-CONVERSATION §2/§5). It uses
|
|
@@ -1368,13 +1391,19 @@
|
|
|
1368
1391
|
#fsRoot .fsproj.on{background:var(--hover);color:var(--ink)}
|
|
1369
1392
|
#fsRoot .fsproj i{width:4px;height:4px;border-radius:50%;background:var(--ink3);flex:0 0 4px;position:relative;top:1.5px} /* top:1.5px = optical nudge so the dot/running-ring centre sits on the name's x-height, not the line-box centre (Masa 2026-07-24, rail=D). Same shift for idle dot + running ring keeps the column aligned. */
|
|
1370
1393
|
#fsRoot .fsproj.on i{background:var(--green)}
|
|
1394
|
+
/* Review-blue comparison B: Aurora Blue #5CC4FF at 78%. This is a dedicated
|
|
1395
|
+
Review token; the app's ordinary blue controls must not inherit it. */
|
|
1396
|
+
#fsRoot .fsproj.rev i{background:rgba(var(--review-rgb),.78);box-shadow:0 0 3px rgba(var(--review-rgb),.24)}
|
|
1371
1397
|
/* running project = solid saturated green dot with a breathing glow — never dims to
|
|
1372
1398
|
grey, independent of selection (HANDOFF-v45 §11, flagship .proj.run port) */
|
|
1373
1399
|
/* running project = a spinning broken green ring (matches the Doing marker · flagship #316).
|
|
1374
1400
|
Was a pulsing dot (fsrundot). Centre WITHOUT transform (spin's rotate would override a centring
|
|
1375
1401
|
translate → drift bottom-right) via top/left:50% + negative-half margin; concentric with the 4px dot. */
|
|
1376
1402
|
#fsRoot .fsproj.run i{background:none;box-shadow:none;position:relative}
|
|
1377
|
-
|
|
1403
|
+
/* Review-blue comparison baseline: the Doing cue is a 7px / 1.3px broken
|
|
1404
|
+
ring, while idle and Review remain 4px dots. */
|
|
1405
|
+
#fsRoot .fsproj.run i::after{content:"";position:absolute;top:50%;left:50%;width:7px;height:7px;margin:-3.5px 0 0 -3.5px;box-sizing:border-box;border-radius:50%;border:1.3px solid var(--green);border-top-color:transparent;animation:spin 1.8s linear infinite}
|
|
1406
|
+
#fsRoot .fsproj.rev i{background:rgba(var(--review-rgb),.78);box-shadow:0 0 3px rgba(var(--review-rgb),.24)}
|
|
1378
1407
|
@keyframes fsrundot{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--green) 50%,transparent)}50%{box-shadow:0 0 5px 1.5px color-mix(in srgb,var(--green) 35%,transparent)}}
|
|
1379
1408
|
@media (prefers-reduced-motion:reduce){#fsRoot .fsproj.run i::after{animation:none}}
|
|
1380
1409
|
#fsRoot .fsproj .pname{min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
@@ -1719,10 +1748,9 @@
|
|
|
1719
1748
|
.lbc-n{font:600 10px/1 var(--mono);color:var(--ink3);font-variant-numeric:tabular-nums;letter-spacing:.02em}
|
|
1720
1749
|
.lbc-dot{width:6px;height:6px;border-radius:50%;flex:0 0 6px;background:var(--ink3)}
|
|
1721
1750
|
.lbc-dot.run{background:var(--green);animation:lbcpulse 2.4s ease-in-out infinite}
|
|
1722
|
-
/* Status dot palette
|
|
1723
|
-
Doing = green
|
|
1724
|
-
|
|
1725
|
-
.lbc-dot.rev{background:#8390f2}
|
|
1751
|
+
/* Status dot palette: Needs you = amber, Review = dedicated Aurora Blue,
|
|
1752
|
+
Doing = green, Done/To Do/Later = grey, Failed = red. */
|
|
1753
|
+
.lbc-dot.rev{background:rgba(var(--review-rgb),.78)}
|
|
1726
1754
|
.lbc-dot.bad{background:var(--danger)}
|
|
1727
1755
|
.lbc-dot.intr{background:#ffca16}
|
|
1728
1756
|
.lbc-dot.ok{background:var(--ink3);opacity:.6}
|
|
@@ -1918,7 +1946,7 @@
|
|
|
1918
1946
|
.lg-hd .no{flex:0 0 auto;font:600 11px/1 var(--mono);color:var(--ink3);font-variant-numeric:tabular-nums}
|
|
1919
1947
|
.lg-hd .t{flex:1;font:600 15px/1.3 var(--ui);letter-spacing:-.014em}
|
|
1920
1948
|
.lg-state{flex:0 0 auto;font:600 9px/1 var(--mono);letter-spacing:.11em;text-transform:uppercase} /* state = colour text only (§5.5 no badge) */
|
|
1921
|
-
.lg-state.review{color:var(--
|
|
1949
|
+
.lg-state.review{color:var(--review)}
|
|
1922
1950
|
/* meta row: PR link / token savings (real tap-to-open toggle) / Run locally — a
|
|
1923
1951
|
compact row directly under the title (H23 §1, pattern 1 won over a plain text
|
|
1924
1952
|
line and title-row-right-aligned chips). */
|
|
@@ -2428,6 +2456,7 @@
|
|
|
2428
2456
|
specificity and greys the running dot out on the Gradient wallpaper rail — this
|
|
2429
2457
|
exception restores solid green so "running" never dims to grey. */
|
|
2430
2458
|
body[data-theme="gradient"] #fsRoot .fsproj.run i{background:none}
|
|
2459
|
+
body[data-theme="gradient"] #fsRoot .fsproj.rev i{background:rgba(var(--review-rgb),.78)}
|
|
2431
2460
|
body[data-theme="gradient"] #fsRoot .fsproj .num,body[data-theme="gradient"] #fsRoot .fsproj .pcount{color:rgba(20,23,31,.6)}
|
|
2432
2461
|
body[data-theme="gradient"] #fsRoot .fsproj:not(.on):hover{background:rgba(255,255,255,.34)}
|
|
2433
2462
|
body[data-theme="gradient"] #fsRoot .fsproj.on{background:rgba(255,255,255,.5);color:var(--ink)}
|
|
@@ -2565,6 +2594,7 @@
|
|
|
2565
2594
|
body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj i{background:rgba(255,255,255,.6)}
|
|
2566
2595
|
/* same GOTCHA exception as the light Gradient rail, dark variant (HANDOFF-v45 §11) */
|
|
2567
2596
|
body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj.run i{background:none}
|
|
2597
|
+
body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj.rev i{background:rgba(var(--review-rgb),.78)}
|
|
2568
2598
|
body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj .num,body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj .pcount{color:rgba(255,255,255,.6)}
|
|
2569
2599
|
body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj.on{background:rgba(255,255,255,.15);color:#fff}
|
|
2570
2600
|
body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj:not(.on):hover{background:rgba(255,255,255,.1)}
|
|
@@ -2928,6 +2958,13 @@
|
|
|
2928
2958
|
</style>
|
|
2929
2959
|
</head>
|
|
2930
2960
|
<body>
|
|
2961
|
+
<main id="galdaInvalidFilePreview" aria-live="assertive">
|
|
2962
|
+
<section class="galda-invalid-file-card">
|
|
2963
|
+
<h1>This is not the running Galda app</h1>
|
|
2964
|
+
<p><code>app/index.html</code> was opened with <code>file://</code>. This source file cannot load the theme, live state, Review data, or realtime updates.</p>
|
|
2965
|
+
<p>Start Galda from the target worktree and open the authenticated <code>http://localhost:<port>/?key=<key></code> URL printed in the terminal.</p>
|
|
2966
|
+
</section>
|
|
2967
|
+
</main>
|
|
2931
2968
|
<div id="themebg"></div>
|
|
2932
2969
|
<input type="file" id="bg3File" accept="image/*" style="display:none"><!-- Banff BACKDROP free upload (v3 §6.5) -->
|
|
2933
2970
|
<canvas id="glassgl"></canvas>
|
|
@@ -3371,7 +3408,7 @@ const savedTheme = (() => {
|
|
|
3371
3408
|
if (v !== s) localStorage.setItem('theme', v);
|
|
3372
3409
|
return v;
|
|
3373
3410
|
})();
|
|
3374
|
-
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,
|
|
3411
|
+
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, composerDetachedGoal: 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,
|
|
3375
3412
|
// per-message send overrides set via slash-commands (reset after each send,
|
|
3376
3413
|
// except model which is sticky in localStorage). palette = open command list.
|
|
3377
3414
|
// Scoped per project (msgByProject) so a skill/mode picked in one project can't leak
|
|
@@ -3414,7 +3451,7 @@ function restoreFocusedAnswerInput(container, attr, saved){
|
|
|
3414
3451
|
}
|
|
3415
3452
|
|
|
3416
3453
|
const STATUS_LABEL = {
|
|
3417
|
-
sending: 'Sending…', stacked: 'Queued', planning: 'Planning…', queued: 'To do', running: 'Working…', needsInput: 'Needs input',
|
|
3454
|
+
sending: 'Sending…', stacked: 'Queued', planning: 'Planning…', queued: 'To do', running: 'Working…', needsInput: 'Needs input', needsApproval: 'Needs you',
|
|
3418
3455
|
review: 'In review', done: 'Done', failed: 'Failed', partial: 'Partly failed', interrupted: 'Interrupted', skipped: 'Skipped',
|
|
3419
3456
|
rejected: 'Dismissed',
|
|
3420
3457
|
};
|
|
@@ -3437,7 +3474,7 @@ const GOAL_CHIP = (s, columns, startedAt) => s === 'stacked' || s === 'sending'
|
|
|
3437
3474
|
s === 'reverted' ? ['REVERTED', ''] :
|
|
3438
3475
|
s === 'rejected' ? ['DISMISSED', 'rejected'] :
|
|
3439
3476
|
s === 'folded' ? ['FOLDED', ''] :
|
|
3440
|
-
s === 'blocked' ? ['BLOCKED', 'blocked'] : s === 'needsInput' ? ['NEEDS INPUT', 'blocked'] : ['FAIL', ''];
|
|
3477
|
+
s === 'blocked' ? ['BLOCKED', 'blocked'] : s === 'needsInput' ? ['NEEDS INPUT', 'blocked'] : s === 'needsApproval' ? ['NEEDS YOU', 'blocked'] : ['FAIL', ''];
|
|
3441
3478
|
const FINISHED = (s) => ['done', 'partial', 'failed', 'interrupted'].includes(s);
|
|
3442
3479
|
const ICONS = {
|
|
3443
3480
|
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>',
|
|
@@ -3725,6 +3762,7 @@ function buildReviewDigest({ goals, tasks } = {}){
|
|
|
3725
3762
|
verificationInfraError: g.verificationInfraError ?? null,
|
|
3726
3763
|
proof: proofTask?.proof ?? null,
|
|
3727
3764
|
proofTaskId: proofTask?.id ?? null,
|
|
3765
|
+
incompleteRequirementCount: goalTasks.reduce((n, t) => n + (t.requirementEvidence?.requirements ?? []).filter((v) => ['partial', 'not-addressed'].includes(v.status)).length, 0),
|
|
3728
3766
|
plan,
|
|
3729
3767
|
planText: plan ? (g.planText ?? '') : null,
|
|
3730
3768
|
};
|
|
@@ -3735,6 +3773,7 @@ function buildReviewDigest({ goals, tasks } = {}){
|
|
|
3735
3773
|
if (existing.proof == null) { existing.proof = row.proof; existing.proofTaskId = row.proofTaskId; }
|
|
3736
3774
|
if (existing.testResult == null) existing.testResult = row.testResult;
|
|
3737
3775
|
if (existing.verificationInfraError == null) existing.verificationInfraError = row.verificationInfraError;
|
|
3776
|
+
existing.incompleteRequirementCount += row.incompleteRequirementCount;
|
|
3738
3777
|
continue;
|
|
3739
3778
|
}
|
|
3740
3779
|
if (row.pr) byPr.set(row.pr, row);
|
|
@@ -4320,8 +4359,9 @@ function mergeGoalTimeline(goals, externalActivity, projectId){
|
|
|
4320
4359
|
// レビュー画面(checklist/detail overlay)からチャットへ戻った時、そのゴールの
|
|
4321
4360
|
// 末尾がすぐ見える位置へスクロールする(task 86)。ゴールが完了していて結論サマリ
|
|
4322
4361
|
// (.gsummary)が出ていればそこを、まだなければゴールメッセージ全体の末尾を狙う。
|
|
4323
|
-
function goToGoalInChat(goalId){
|
|
4362
|
+
function goToGoalInChat(goalId, bindComposer = true){
|
|
4324
4363
|
state.selectedGoal = goalId;
|
|
4364
|
+
if (bindComposer) state.composerDetachedGoal = null;
|
|
4325
4365
|
renderReplyBind(); // Feature A: the composer's "Replying to #NNN" chip follows the focused goal — renderStream() alone skips it (only render() calls it)
|
|
4326
4366
|
renderStream();
|
|
4327
4367
|
const goalEl = document.querySelector(`#stream [data-goalmsg="${goalId}"]`);
|
|
@@ -4663,8 +4703,17 @@ function renderTasks(){
|
|
|
4663
4703
|
<div class="qansrow"><input type="text" data-qansinput="${g.id}" placeholder="Type your answer…"><button type="button" class="qbtn ok" data-qanssend="${g.id}" aria-label="Send">↑</button></div>
|
|
4664
4704
|
<div class="qsub">${esc((g.text ?? '').replace(/\s+/g, ' ').slice(0, 54))}</div></div>`;
|
|
4665
4705
|
}).join('');
|
|
4666
|
-
const
|
|
4667
|
-
|
|
4706
|
+
const approvalCards = pgoals.filter((g) => g.status === 'needsApproval' && g.approvalRequest).map((g) => {
|
|
4707
|
+
const a = g.approvalRequest;
|
|
4708
|
+
const scope = (a.scope || []).map((x) => `• ${esc(x)}`).join('\n');
|
|
4709
|
+
return `<div class="qflow approvalflow" data-approvalgoal="${g.id}"><div class="qh">◆ Needs your approval</div><div class="qq">${esc(a.question || g.text)}</div>
|
|
4710
|
+
${scope ? `<div class="qscope">${scope}</div>` : ''}
|
|
4711
|
+
<div class="qsub">${esc(a.action || 'continue')}</div>
|
|
4712
|
+
<div class="qactions"><button type="button" class="qaction primary" data-approval="proceed" data-goal="${g.id}">Proceed</button><button type="button" class="qaction" data-approvaledit="${g.id}">Edit scope</button><button type="button" class="qaction cancel" data-approval="cancel" data-goal="${g.id}">Cancel</button></div>
|
|
4713
|
+
<div class="qansrow" data-approvaleditrow="${g.id}" hidden><input type="text" data-approvalinput="${g.id}" placeholder="Describe the allowed scope…"><button type="button" class="qbtn ok" data-approval="edit" data-goal="${g.id}" aria-label="Apply and proceed">↑</button></div></div>`;
|
|
4714
|
+
}).join('');
|
|
4715
|
+
const needsYou = approvalCards + questionCards;
|
|
4716
|
+
const todo = grouped(todoShown, (t, i) => (i === 0 ? '<div class="upnextlbl">UP NEXT</div>' : '') + row(t, { drag: 1, idx: i }))
|
|
4668
4717
|
+ (todoAll.length > TODO_LIMIT ? `<div class="more" data-todomore="1">${state.showAllTodo ? 'show less' : `more · +${todoAll.length - TODO_LIMIT}`}</div>` : '');
|
|
4669
4718
|
// Reviews are numbered by their own original TODO number (task 118), not
|
|
4670
4719
|
// by position in this list — that number matches the dialog header
|
|
@@ -4738,7 +4787,8 @@ function renderTasks(){
|
|
|
4738
4787
|
<button type="button" class="pdel" data-pdel="${g.id}" title="Delete">✕</button></div>`).join('');
|
|
4739
4788
|
const savedQAns = captureFocusedAnswerInput($('tasklist'), 'data-qansinput');
|
|
4740
4789
|
$('tasklist').innerHTML =
|
|
4741
|
-
sec('
|
|
4790
|
+
sec('Needs you', needsYou, 'needsyou')
|
|
4791
|
+
+ sec('Attention', attention, 'attn')
|
|
4742
4792
|
+ sec(pickColumnLabel(cols, 'review', 'Review'), reviewDigest + review, 'reviewsec', reviewDefinitionFor(state.active).description, REVIEW_DEF_HINT)
|
|
4743
4793
|
+ sec(pickColumnLabel(cols, 'doing', 'Doing'), doing)
|
|
4744
4794
|
+ sec(pickColumnLabel(cols, 'todo', 'To do'), todo, 'todosec')
|
|
@@ -4920,6 +4970,25 @@ function renderTasks(){
|
|
|
4920
4970
|
for (const b of $('tasklist').querySelectorAll('[data-qanswer]')) b.onclick = (e) => { e.stopPropagation(); answerQ(Number(b.dataset.qanswer), b.dataset.answer); };
|
|
4921
4971
|
for (const b of $('tasklist').querySelectorAll('[data-qanssend]')) b.onclick = (e) => { e.stopPropagation(); const inp = $('tasklist').querySelector(`[data-qansinput="${b.dataset.qanssend}"]`); answerQ(Number(b.dataset.qanssend), inp && inp.value.trim()); };
|
|
4922
4972
|
for (const inp of $('tasklist').querySelectorAll('[data-qansinput]')) inp.onkeydown = (e) => { if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); answerQ(Number(inp.dataset.qansinput), inp.value.trim()); } };
|
|
4973
|
+
for (const b of $('tasklist').querySelectorAll('[data-approvaledit]')) b.onclick = (e) => {
|
|
4974
|
+
e.stopPropagation();
|
|
4975
|
+
const row = $('tasklist').querySelector(`[data-approvaleditrow="${b.dataset.approvaledit}"]`);
|
|
4976
|
+
if (row) { row.hidden = !row.hidden; if (!row.hidden) row.querySelector('input')?.focus(); }
|
|
4977
|
+
};
|
|
4978
|
+
const decideApproval = async (goalId, decision, instruction = '') => {
|
|
4979
|
+
const r = await fetch(withKey(`/api/goals/${goalId}/approval`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ decision, instruction }) });
|
|
4980
|
+
if (!r.ok) showErr((await r.json().catch(() => null))?.error || 'Approval decision failed'); else await refresh();
|
|
4981
|
+
};
|
|
4982
|
+
for (const b of $('tasklist').querySelectorAll('[data-approval]')) b.onclick = (e) => {
|
|
4983
|
+
e.stopPropagation();
|
|
4984
|
+
const instruction = b.dataset.approval === 'edit' ? ($('tasklist').querySelector(`[data-approvalinput="${b.dataset.goal}"]`)?.value.trim() || '') : '';
|
|
4985
|
+
if (b.dataset.approval === 'edit' && !instruction) return;
|
|
4986
|
+
decideApproval(Number(b.dataset.goal), b.dataset.approval, instruction);
|
|
4987
|
+
};
|
|
4988
|
+
for (const inp of $('tasklist').querySelectorAll('[data-approvalinput]')) inp.onkeydown = (e) => {
|
|
4989
|
+
if (e.key !== 'Enter' || e.isComposing || !inp.value.trim()) return;
|
|
4990
|
+
e.preventDefault(); decideApproval(Number(inp.dataset.approvalinput), 'edit', inp.value.trim());
|
|
4991
|
+
};
|
|
4923
4992
|
// B mode: tap an "未送信" outbox row to resend it.
|
|
4924
4993
|
for (const el of $('tasklist').querySelectorAll('[data-oresend]')) el.onclick = () => { const item = outbox.load().find((x) => x.oid === el.dataset.oresend); if (item) postOutboxItem(item); };
|
|
4925
4994
|
const moreEl = $('tasklist').querySelector('[data-todomore]');
|
|
@@ -5318,7 +5387,7 @@ function closeReviewChecklist(){
|
|
|
5318
5387
|
state.reviewOpenGoal = null;
|
|
5319
5388
|
// task 86: レビュー画面からチャットへ戻る瞬間に、そのゴールの末尾(完了して
|
|
5320
5389
|
// いれば結論サマリ)がすぐ見える位置へスクロールする。
|
|
5321
|
-
if (goalId != null) goToGoalInChat(goalId);
|
|
5390
|
+
if (goalId != null) goToGoalInChat(goalId, false);
|
|
5322
5391
|
}
|
|
5323
5392
|
function renderReviewChecklist(){
|
|
5324
5393
|
const goalId = state.reviewOpenGoal;
|
|
@@ -5368,7 +5437,10 @@ function renderReviewChecklist(){
|
|
|
5368
5437
|
// own test result — so a reviewer can judge at a glance, before scrolling.
|
|
5369
5438
|
const grs = goal.reviewSummary ?? {};
|
|
5370
5439
|
const gtr = goal.testResult;
|
|
5371
|
-
const
|
|
5440
|
+
const testPreflightFailed = gtr?.preflight && !gtr.preflight.ok;
|
|
5441
|
+
const testLine = gtr?.ran ? (testPreflightFailed
|
|
5442
|
+
? `<span class="tbadge">tests need environment setup</span>`
|
|
5443
|
+
: `<span class="tbadge ${gtr.ok ? 'ok' : 'no'}">tests ${gtr.passed}✓${gtr.failed ? ` / ${gtr.failed}✗` : ''}</span>`) : '';
|
|
5372
5444
|
// #377-7 (Masa, recurring): the base UI is ALWAYS English — Japanese is a
|
|
5373
5445
|
// per-project CONTENT customization, never chrome. So the fixed system labels
|
|
5374
5446
|
// below stay English regardless of reviewDefinition.language; only the
|
|
@@ -5398,7 +5470,7 @@ function renderReviewChecklist(){
|
|
|
5398
5470
|
vchips.push(`<span class="vchip none">${esc(workspaceModeText(goal))}</span>`);
|
|
5399
5471
|
if (goal.workspaceWarning) vchips.push(`<span class="vchip human high">⚠ ${esc(goal.workspaceWarning.slice(0, 80))}</span>`);
|
|
5400
5472
|
vchips.push(gtr?.ran
|
|
5401
|
-
? (gtr.ok ? `<span class="vchip ok">✓ ${VF.pass}</span>` : `<span class="vchip no">✗ ${VF.fail}</span>`)
|
|
5473
|
+
? (testPreflightFailed ? `<span class="vchip human">! tests need setup</span>` : gtr.ok ? `<span class="vchip ok">✓ ${VF.pass}</span>` : `<span class="vchip no">✗ ${VF.fail}</span>`)
|
|
5402
5474
|
: `<span class="vchip none">— ${VF.untested}</span>`);
|
|
5403
5475
|
vchips.push(hasProof ? `<span class="vchip ok">✓ ${VF.proof}</span>` : `<span class="vchip none">— ${VF.noproof}</span>`);
|
|
5404
5476
|
if (risk?.shouldPause) vchips.push(`<span class="vchip human high">⚠ ${VF.human}</span>`);
|
|
@@ -6031,9 +6103,11 @@ async function sendReviewDismiss(text){
|
|
|
6031
6103
|
if (body.task) upsert(state.tasks, body.task);
|
|
6032
6104
|
$('reviewFixInput').value = '';
|
|
6033
6105
|
reviewAttach = []; renderReviewAttach();
|
|
6034
|
-
//
|
|
6035
|
-
//
|
|
6036
|
-
|
|
6106
|
+
// Keep the centre conversation focused on this work, but detach only the
|
|
6107
|
+
// composer. Review is a live progress surface and stays open until Close.
|
|
6108
|
+
state.selectedGoal = goal.id;
|
|
6109
|
+
state.composerDetachedGoal = goal.id;
|
|
6110
|
+
state.openThreads.add(goal.id);
|
|
6037
6111
|
render();
|
|
6038
6112
|
} catch { showErr('Dismiss failed.'); }
|
|
6039
6113
|
}
|
|
@@ -6419,13 +6493,23 @@ function renderFsRail(){
|
|
|
6419
6493
|
const pgoals = state.goals.filter((g) => g.projectId === p.id);
|
|
6420
6494
|
// running dot (HANDOFF-v45 §11): a REAL running task, independent of selection —
|
|
6421
6495
|
// .on already means "selected", .run means "actually doing work right now".
|
|
6422
|
-
|
|
6496
|
+
// Was ptasks.some(status==='running') only — missed goals sitting in the board's
|
|
6497
|
+
// 'doing' bucket before their first task exists yet (e.g. still 'planning'), so a
|
|
6498
|
+
// project the To Do/Kanban view showed as Doing kept a static dot in the rail
|
|
6499
|
+
// (Masa report 2026-07-27: "全部doingなのに緑のアイコンが回ってない"). Match the
|
|
6500
|
+
// same bucket the board/list already use, minus Paused (intentionally static).
|
|
6501
|
+
const running = pgoals.some((g) => fsBoardBucket(g, ptasks) === 'doing'
|
|
6502
|
+
&& !(g.status === 'blocked' && g.blocked?.kind === 'paused'));
|
|
6423
6503
|
const td = ptasks.filter((t) => ['running', 'queued'].includes(t.status)).length;
|
|
6424
6504
|
const rev = buildReviewDigest({ goals: pgoals, tasks: ptasks }).length;
|
|
6425
6505
|
const pcount = `<span class="pcount" title="${rev} review${rev === 1 ? '' : 's'} ready · ${td} to-do">`
|
|
6426
6506
|
+ `<span class="pc-item rev${rev ? '' : ' zero'}">${FS_ICO_SCAN}${rev}</span>`
|
|
6427
6507
|
+ `<span class="pc-item td">${FS_ICO_TD}${td}</span></span>`;
|
|
6428
|
-
|
|
6508
|
+
// The left dot itself, not just the count beside it, should read "review waiting"
|
|
6509
|
+
// (Masa 2026-07-27: "青で表示されるのは左の◯もね") — reuses the same Review blue
|
|
6510
|
+
// as pc-item.rev/.lbc-dot.rev. Running (spinning) wins when both are true.
|
|
6511
|
+
const revCls = !running && rev ? ' rev' : '';
|
|
6512
|
+
return `<button class="fsproj${p.id === state.active ? ' on' : ''}${running ? ' run' : ''}${revCls}" data-fsproj="${p.id}"><i></i><span class="pname">${esc(p.name)}</span>${pcount}</button>`;
|
|
6429
6513
|
}).join('')
|
|
6430
6514
|
+ `<button class="fsprojadd" id="fsProjAdd" title="New project"><span class="pa-plus"></span><span class="pa-label">New project</span></button>`;
|
|
6431
6515
|
// Skip the DOM swap when the rail is unchanged: re-creating .fsproj.run would restart the
|
|
@@ -6628,7 +6712,12 @@ function renderFsFeed(){
|
|
|
6628
6712
|
if (!out && hasWork) {
|
|
6629
6713
|
const laterN = pgoals.filter((g) => g.status === 'pending').length;
|
|
6630
6714
|
const queuedN = ptasks.filter((t) => t.status === 'queued').length + pgoals.filter((g) => ['planning', 'stacked'].includes(g.status)).length;
|
|
6631
|
-
const
|
|
6715
|
+
const selectedQueued = [...ptasks].reverse().find((t) => t.status === 'queued' && t.goalId === state.selectedGoal)
|
|
6716
|
+
?? [...ptasks].reverse().find((t) => t.status === 'queued');
|
|
6717
|
+
const queuedText = selectedQueued
|
|
6718
|
+
? `Queued · ${String(selectedQueued.title ?? '').replace(/\s+/g, ' ').slice(0, 90)} · waiting to start`
|
|
6719
|
+
: `${queuedN} queued · waiting to start`;
|
|
6720
|
+
const msg = running.length ? 'working…' : queuedN ? queuedText : laterN ? `no active task · ${laterN} saved in Later` : 'no active task';
|
|
6632
6721
|
out = `<div class="cc-c">⎿ ${esc(msg)}</div>`;
|
|
6633
6722
|
}
|
|
6634
6723
|
$('fsRoot').classList.toggle('clawdhero', !hasWork);
|
|
@@ -6666,6 +6755,13 @@ async function fsAnswerQuestion(goalId, answer){
|
|
|
6666
6755
|
catch { showErr('Failed to send answer'); return; }
|
|
6667
6756
|
if (!r.ok) showErr('Failed to send answer'); else await refresh();
|
|
6668
6757
|
}
|
|
6758
|
+
async function submitApprovalDecision(goalId, decision, instruction = ''){
|
|
6759
|
+
if (decision === 'edit' && !String(instruction).trim()) return;
|
|
6760
|
+
let r;
|
|
6761
|
+
try { r = await fetch(withKey(`/api/goals/${goalId}/approval`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ decision, instruction }) }); }
|
|
6762
|
+
catch { showErr('Approval decision failed'); return; }
|
|
6763
|
+
if (!r.ok) showErr((await r.json().catch(() => null))?.error || 'Approval decision failed'); else await refresh();
|
|
6764
|
+
}
|
|
6669
6765
|
async function fsRetryTask(id){
|
|
6670
6766
|
const r = await fetch(withKey(`/api/tasks/${id}/retry`), { method: 'POST' });
|
|
6671
6767
|
if (!r.ok) showErr('Retry failed.'); else await refresh();
|
|
@@ -6741,6 +6837,7 @@ function fsBoardBucket(goal, tasks){
|
|
|
6741
6837
|
const s = goal?.status;
|
|
6742
6838
|
if (s === 'pending') return 'later'; // shelved by the composer's "Later"
|
|
6743
6839
|
if (s === 'needsInput') return 'needs'; // a clarifying question is waiting on the user
|
|
6840
|
+
if (s === 'needsApproval') return 'needs'; // an explicit go/no-go is waiting; never Review
|
|
6744
6841
|
if (s === 'blocked' && goal?.blocked?.kind === 'paused') return 'doing'; // paused by user — stays in Doing, shown amber "Paused" (Masa 2026-07-19: 1カードのまま黄色に・Needs youへ移動しない)
|
|
6745
6842
|
if (['blocked', 'failed', 'partial', 'interrupted'].includes(s)) return 'needs'; // stopped/errored — needs YOUR action (retry), like the flagship board which puts .drow.err in Needs you. Not "Doing" — Doing is only what's actively moving (Masa 2026-07-18)
|
|
6746
6843
|
if (s === 'review' || s === 'folded') return 'review'; // folded (PR filed for review) reads as Review, not Doing — goalGroupStatus parity (Masa 2026-07-21)
|
|
@@ -6892,7 +6989,7 @@ function renderFsBoard(){
|
|
|
6892
6989
|
col('To Do', 'var(--ink3)', bucket.todo.length, bucket.todo.map(goalCard).join(''), 'todo')
|
|
6893
6990
|
+ col('Doing', 'var(--green)', bucket.doing.length, bucket.doing.map(goalCard).join(''), 'doing')
|
|
6894
6991
|
+ (bucket.needs.length ? col('Needs you', '#ffca16', bucket.needs.length, bucket.needs.map(goalCard).join(''), 'needs') : '')
|
|
6895
|
-
+ col('Review', '
|
|
6992
|
+
+ col('Review', 'var(--review)', reviewDigest.length, reviewDigest.map(reviewCard).join(''), 'review')
|
|
6896
6993
|
+ col('Done', 'var(--ink3)', bucket.done.length, bucket.done.slice(0, 40).map(goalCard).join(''), 'done')
|
|
6897
6994
|
+ col('Later', 'var(--ink3)', bucket.later.length, bucket.later.map(goalCard).join(''), 'later');
|
|
6898
6995
|
for (const b of el.querySelectorAll('[data-fsnew]')) b.onclick = () => $('input')?.focus(); // every + New / header + focuses the composer
|
|
@@ -6921,7 +7018,7 @@ function togglePauseActive(){
|
|
|
6921
7018
|
// width. They announce themselves beside the List/Board toggle instead — pure read of the same
|
|
6922
7019
|
// buckets the board just rendered (no server call). Colour on the dot only; a column already
|
|
6923
7020
|
// in view sinks. Only the columns with items are drawn — zero waiting shows nothing.
|
|
6924
|
-
const FS_WAIT = [['needs', 'Needs you', '#ffca16'], ['review', 'Review', '
|
|
7021
|
+
const FS_WAIT = [['needs', 'Needs you', '#ffca16'], ['review', 'Review', 'var(--review)']];
|
|
6925
7022
|
function fsWaitPlace(){
|
|
6926
7023
|
const bar = $('fsWait'), seg = $('fsTvSeg'), lane = $('fsLane');
|
|
6927
7024
|
if (!bar || !seg || !lane) return;
|
|
@@ -6994,6 +7091,7 @@ function renderFsReview(){
|
|
|
6994
7091
|
// retest ×3 note (v45 §5.3): the goal came back green from a Ledger Dismiss
|
|
6995
7092
|
const g0 = pgoals.find((g) => g.id === r.goalIds[0]);
|
|
6996
7093
|
const rnote = g0?.retest?.passed ? `<span class="rnote">✓ ${esc(g0.retest.note)}</span>` : '';
|
|
7094
|
+
const incompleteNote = r.incompleteRequirementCount ? `<span class="rnote">${r.incompleteRequirementCount} requirement${r.incompleteRequirementCount === 1 ? '' : 's'} need review</span>` : '';
|
|
6997
7095
|
const infraNote = r.verificationInfraError?.detail
|
|
6998
7096
|
? `<span class="rnote">検証基盤エラー · <button type="button" class="retrybtn" data-goalreverify="${r.goalIds[0]}">再検証</button></span>`
|
|
6999
7097
|
: '';
|
|
@@ -7008,7 +7106,7 @@ function renderFsReview(){
|
|
|
7008
7106
|
// (Masa 2026-07-20: this card showed reviewSummary.check "…が出ない" while the list
|
|
7009
7107
|
// showed the request text). Plan-reviews keep their "PLAN — approve to execute:"
|
|
7010
7108
|
// checkLine, which is the actionable label there, not a title mismatch.
|
|
7011
|
-
return `<div class="rcard noimg" data-fsrev="${r.goalIds[0]}"><div class="rc"><div class="t">${rid}${esc(r.plan ? r.checkLine : saReviewTitle(g0))}</div>${rnote}${infraNote}${pr}${conflictNote}</div>
|
|
7109
|
+
return `<div class="rcard noimg" data-fsrev="${r.goalIds[0]}"><div class="rc"><div class="t">${rid}${esc(r.plan ? r.checkLine : saReviewTitle(g0))}</div>${rnote}${incompleteNote}${infraNote}${pr}${conflictNote}</div>
|
|
7012
7110
|
<div class="act"><button class="b" data-fsapprove="${ids}" title="Approve">✓</button><button class="b no" data-fsdismiss="${ids}" title="Send back">✕</button></div></div>`;
|
|
7013
7111
|
};
|
|
7014
7112
|
// V3-C (Masa決定 2026-07-07): no collapsed strip — it duplicated the sm2 meter right
|
|
@@ -7076,6 +7174,7 @@ function fsBucketTodo(){
|
|
|
7076
7174
|
failed: list.filter((t) => ['failed', 'interrupted'].includes(t.status)),
|
|
7077
7175
|
blocked: pgoals.filter((g) => g.status === 'blocked'),
|
|
7078
7176
|
needsInput: pgoals.filter((g) => g.status === 'needsInput'), // a clarifying question is waiting on the user (board's "Needs you")
|
|
7177
|
+
needsApproval: pgoals.filter((g) => g.status === 'needsApproval'),
|
|
7079
7178
|
reviewGoals: pgoals.filter((g) => g.status === 'review'),
|
|
7080
7179
|
queued: list.filter((t) => t.status === 'queued' && !t.reply).sort(byQueue),
|
|
7081
7180
|
// A reply task's completion is already visible inline in the center-lane timeline
|
|
@@ -7183,7 +7282,7 @@ function errorButtonsHtml(meta, { taskId = null, goalId = null, interrupted = fa
|
|
|
7183
7282
|
// error as a small in-row colour element (§1.5-8), quiet Done fold.
|
|
7184
7283
|
function renderFsTodo(){
|
|
7185
7284
|
const el = $('fsTodoSec'); if (!el) return;
|
|
7186
|
-
const { pgoals, list, running, failed, blocked, needsInput, queued, doneAll, later, rejected } = fsBucketTodo();
|
|
7285
|
+
const { pgoals, list, running, failed, blocked, needsInput, needsApproval, queued, doneAll, later, rejected } = fsBucketTodo();
|
|
7187
7286
|
const paused = !!state.projects.find((p) => p.id === state.active)?.paused; // list版 Doing トグルの状態
|
|
7188
7287
|
// 一時停止したゴール(blocked kind:'paused')は Doing に「Paused」1行で留める(Masa 2026-07-19)。
|
|
7189
7288
|
// Needs you 側(failed/blocked)へは出さず、その走行/中断タスク行も抑止して二重行を作らない。
|
|
@@ -7273,7 +7372,10 @@ function renderFsTodo(){
|
|
|
7273
7372
|
<div class="acts2">${errorButtonsHtml(meta, { goalId: g.id, agent: g.agent })}<button class="errbtn" data-fsarchive="${g.id}">Archive</button></div></div></div>`; }).join('')
|
|
7274
7373
|
+ needsInput.map((g) => `<div class="drow err int"><div class="trow now" data-fsgoalrow="${g.id}"><span class="st run int"></span>${noSpan(g.id)}<span class="tt">${tt(g.text || g.plan?.[0], 60)}</span><span class="stword int">Needs input</span></div>
|
|
7275
7374
|
<div class="errline"><div class="msg"><span class="x int">?</span>${tt(g.question?.text || 'A question is waiting on you', 180)}</div>
|
|
7276
|
-
<div class="acts2"><button class="errbtn" data-fsglog="${g.id}">Answer</button></div></div></div>`).join('')
|
|
7375
|
+
<div class="acts2"><button class="errbtn" data-fsglog="${g.id}">Answer</button></div></div></div>`).join('')
|
|
7376
|
+
+ needsApproval.map((g) => { const a = g.approvalRequest || {}; const scope = (a.scope || []).join(' · '); return `<div class="drow err int"><div class="trow now" data-fsgoalrow="${g.id}"><span class="st run int"></span>${noSpan(g.id)}<span class="tt">${tt(a.question || g.text, 80)}</span><span class="stword int">Approval</span></div>
|
|
7377
|
+
<div class="errline"><div class="msg"><span class="x int">?</span>${tt(scope || a.action || 'Permission required', 180)}</div>
|
|
7378
|
+
<div class="acts2"><button class="errbtn" data-fsapproval="proceed" data-goal="${g.id}">Proceed</button><button class="errbtn" data-fsapprovaledit="${g.id}">Edit scope</button><button class="errbtn" data-fsapproval="cancel" data-goal="${g.id}">Cancel</button></div></div></div>`; }).join('');
|
|
7277
7379
|
// Parked over-cap replies ride at the TOP of To Do (above the ordinary Next-up
|
|
7278
7380
|
// queue), as neutral "Queued" rows — the same goal-row shape as a resumed Doing
|
|
7279
7381
|
// goal but without the green "Working on it", because no worker is running yet.
|
|
@@ -7300,7 +7402,7 @@ function renderFsTodo(){
|
|
|
7300
7402
|
// Keep executable work and deliberately-shelved Later work visibly distinct.
|
|
7301
7403
|
// "To Do 6" while all six were in Later made an idle queue look broken: Later
|
|
7302
7404
|
// never auto-starts by design. The header now tells the operational truth.
|
|
7303
|
-
const needsCount = failed.filter((t) => !resumedGoalIds.has(t.goalId)).length + blocked.length + needsInput.length;
|
|
7405
|
+
const needsCount = failed.filter((t) => !resumedGoalIds.has(t.goalId)).length + blocked.length + needsInput.length + needsApproval.length;
|
|
7304
7406
|
const doingCount = running.length + finalizingGoals.length + pausedGoalIds.size;
|
|
7305
7407
|
const nextCount = parkedReplyGoals.length + queued.length;
|
|
7306
7408
|
// Free-tier pending counter (H22): on the free plan the header shows the wall
|
|
@@ -7371,6 +7473,14 @@ function renderFsTodo(){
|
|
|
7371
7473
|
for (const b of el.querySelectorAll('[data-fsprretry]')) b.onclick = (e) => { e.stopPropagation(); fsRetryPrGoal(b); };
|
|
7372
7474
|
for (const b of el.querySelectorAll('[data-fslog]')) b.onclick = () => fsToggleLog(Number(b.dataset.fslog));
|
|
7373
7475
|
for (const b of el.querySelectorAll('[data-fsglog]')) b.onclick = () => toggleGoalDetail(Number(b.dataset.fsglog));
|
|
7476
|
+
for (const b of el.querySelectorAll('[data-fsapproval]')) b.onclick = async (e) => {
|
|
7477
|
+
e.stopPropagation(); await submitApprovalDecision(Number(b.dataset.goal), b.dataset.fsapproval);
|
|
7478
|
+
};
|
|
7479
|
+
for (const b of el.querySelectorAll('[data-fsapprovaledit]')) b.onclick = (e) => {
|
|
7480
|
+
e.stopPropagation(); fsInlineInput(b.closest('.drow'), '', 'Describe the allowed scope…', async (instruction) => {
|
|
7481
|
+
await submitApprovalDecision(Number(b.dataset.fsapprovaledit), 'edit', instruction);
|
|
7482
|
+
});
|
|
7483
|
+
};
|
|
7374
7484
|
for (const b of el.querySelectorAll('[data-fsreverify]')) b.onclick = async () => {
|
|
7375
7485
|
b.disabled = true; b.textContent = 'Starting…';
|
|
7376
7486
|
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsreverify}/reverify`), { method: 'POST' }); }
|
|
@@ -8068,14 +8178,15 @@ function showBillingBlocked(blocked){
|
|
|
8068
8178
|
|
|
8069
8179
|
const AUTH_COPY = {
|
|
8070
8180
|
ja: {
|
|
8071
|
-
connectTitle: '接続が必要です', connectBody: 'Claude Codeとの接続が切れています。ボタンから接続してください。', connect: 'Claude Codeを接続', retry: '接続を確認',
|
|
8181
|
+
connectTitle: '接続が必要です', connectBody: 'Claude Codeとの接続が切れています。ボタンから接続してください。', connect: 'Claude Codeを接続', retry: '接続を確認', checking: '確認中…',
|
|
8072
8182
|
limitTitle: 'Claudeは利用上限です', limitBody: '作業は安全に待機しています。すぐ続ける場合は、待機中のタスクをCodexへ切り替えられます。', limitWait: '作業は安全に待機しています。Claudeが再び使えるようになると自動で再開します。', codex: 'Codexで続ける',
|
|
8073
8183
|
},
|
|
8074
8184
|
en: {
|
|
8075
|
-
connectTitle: 'Connect Claude Code', connectBody: 'Claude Code is disconnected. Use the button to reconnect and continue.', connect: 'Connect Claude Code', retry: 'Check connection',
|
|
8185
|
+
connectTitle: 'Connect Claude Code', connectBody: 'Claude Code is disconnected. Use the button to reconnect and continue.', connect: 'Connect Claude Code', retry: 'Check connection', checking: 'Checking…',
|
|
8076
8186
|
limitTitle: 'Claude limit reached', limitBody: 'Your work is safely waiting. Switch the waiting tasks to Codex if you want to continue now.', limitWait: 'Your work is safely waiting and will resume automatically when Claude is available.', codex: 'Continue with Codex',
|
|
8077
8187
|
},
|
|
8078
8188
|
};
|
|
8189
|
+
let authConnectionOpened = false;
|
|
8079
8190
|
function renderAuthBanner(auth){
|
|
8080
8191
|
const bar = $('errbar');
|
|
8081
8192
|
state.auth = auth ?? state.auth;
|
|
@@ -8089,7 +8200,7 @@ function renderAuthBanner(auth){
|
|
|
8089
8200
|
+ `<div class="bx-q">${limited ? (canUseCodex ? copy.limitBody : copy.limitWait) : copy.connectBody}</div></div>`
|
|
8090
8201
|
+ `<div class="bx-act">${limited && canUseCodex
|
|
8091
8202
|
? `<button class="bx-cta" id="bxUseCodex">${copy.codex}</button>`
|
|
8092
|
-
: limited ? '' : `<button class="bx-cta" id="
|
|
8203
|
+
: limited ? '' : `<button class="bx-cta" id="bxClaudeAuthAction" data-auth-phase="${authConnectionOpened ? 'verify' : 'connect'}"><span class="bx-spin"></span><span data-auth-label>${authConnectionOpened ? copy.retry : copy.connect}</span></button>`}</div></div>`;
|
|
8093
8204
|
bar.style.display = 'block';
|
|
8094
8205
|
clearTimeout(errTimer); // this banner persists — no auto-hide
|
|
8095
8206
|
const codex = bar.querySelector('#bxUseCodex');
|
|
@@ -8105,30 +8216,39 @@ function renderAuthBanner(auth){
|
|
|
8105
8216
|
} catch (e) { showErr(e.message || 'Could not switch to Codex.'); }
|
|
8106
8217
|
finally { codex.disabled = false; }
|
|
8107
8218
|
};
|
|
8108
|
-
const
|
|
8109
|
-
if (
|
|
8110
|
-
|
|
8219
|
+
const authAction = bar.querySelector('#bxClaudeAuthAction');
|
|
8220
|
+
if (authAction) authAction.onclick = async () => {
|
|
8221
|
+
const phase = authAction.dataset.authPhase;
|
|
8222
|
+
authAction.disabled = true;
|
|
8223
|
+
authAction.classList.add('loading');
|
|
8224
|
+
const label = authAction.querySelector('[data-auth-label]');
|
|
8225
|
+
if (label) label.textContent = phase === 'verify' ? copy.checking : copy.connect;
|
|
8111
8226
|
try {
|
|
8112
|
-
const
|
|
8113
|
-
|
|
8114
|
-
showToast(copy === AUTH_COPY.ja ? '接続画面を開きました。完了後に「接続を確認」を押してください。' : 'Connection opened. When finished, choose “Check connection”.');
|
|
8115
|
-
} catch { showErr(copy === AUTH_COPY.ja ? '接続画面を開けませんでした。' : 'Could not open the connection.'); }
|
|
8116
|
-
finally { connect.disabled = false; }
|
|
8117
|
-
};
|
|
8118
|
-
const retry = bar.querySelector('#bxRetryClaude');
|
|
8119
|
-
if (retry) retry.onclick = async () => {
|
|
8120
|
-
retry.disabled = true;
|
|
8121
|
-
try {
|
|
8122
|
-
const r = await fetch(withKey('/api/worker-auth/retry'), { method: 'POST' });
|
|
8227
|
+
const endpoint = phase === 'verify' ? '/api/worker-auth/retry' : '/api/worker-auth/connect';
|
|
8228
|
+
const r = await fetch(withKey(endpoint), { method: 'POST' });
|
|
8123
8229
|
const body = await r.json().catch(() => ({}));
|
|
8124
8230
|
if (!r.ok) throw new Error();
|
|
8125
|
-
|
|
8126
|
-
|
|
8127
|
-
|
|
8128
|
-
|
|
8231
|
+
if (phase === 'connect') {
|
|
8232
|
+
authConnectionOpened = true;
|
|
8233
|
+
renderAuthBanner(state.auth);
|
|
8234
|
+
showToast(copy === AUTH_COPY.ja ? '接続画面を開きました。完了後に「接続を確認」を押してください。' : 'Connection opened. When finished, choose “Check connection”.');
|
|
8235
|
+
} else {
|
|
8236
|
+
renderAuthBanner(body.auth);
|
|
8237
|
+
if (body.auth?.ok) showToast(copy === AUTH_COPY.ja ? '接続できました。' : 'Connected.');
|
|
8238
|
+
}
|
|
8239
|
+
} catch {
|
|
8240
|
+
showErr(phase === 'verify'
|
|
8241
|
+
? (copy === AUTH_COPY.ja ? 'まだ接続できていません。' : 'Not connected yet.')
|
|
8242
|
+
: (copy === AUTH_COPY.ja ? '接続画面を開けませんでした。' : 'Could not open the connection.'));
|
|
8243
|
+
} finally {
|
|
8244
|
+
authAction.disabled = false;
|
|
8245
|
+
authAction.classList.remove('loading');
|
|
8246
|
+
if (label) label.textContent = phase === 'verify' ? copy.retry : copy.connect;
|
|
8247
|
+
}
|
|
8129
8248
|
};
|
|
8130
8249
|
} else if (authDown) {
|
|
8131
8250
|
authDown = false;
|
|
8251
|
+
authConnectionOpened = false;
|
|
8132
8252
|
bar.classList.remove('billing');
|
|
8133
8253
|
bar.style.display = 'none';
|
|
8134
8254
|
bar.innerHTML = '';
|
|
@@ -8855,6 +8975,10 @@ const REPLY_PLAIN = new Set(['running', 'partial', 'failed', 'interrupted', 'blo
|
|
|
8855
8975
|
function replyBinding(){
|
|
8856
8976
|
const id = state.selectedGoal;
|
|
8857
8977
|
if (id == null) return null;
|
|
8978
|
+
// Focus and reply-target are separate concepts. A Review reply keeps this goal
|
|
8979
|
+
// selected so its live work remains in the centre feed, while the composer goes
|
|
8980
|
+
// back to new-goal mode and shows no stale “Replying to” chip.
|
|
8981
|
+
if (state.composerDetachedGoal === id) return null;
|
|
8858
8982
|
const g = state.goals.find((x) => x.id === id);
|
|
8859
8983
|
if (!g || g.projectId !== state.active) return null; // stale after a project switch → new-goal mode
|
|
8860
8984
|
// needsInput has NO worker session yet (planGoal() sets it before any task/worker
|
|
@@ -8929,10 +9053,9 @@ async function send(){
|
|
|
8929
9053
|
const body = await replyToGoal(bind.goal.id, fullText, '#input');
|
|
8930
9054
|
if (body) {
|
|
8931
9055
|
upsert(state.goals, body.goal); if (body.task) upsert(state.tasks, body.task);
|
|
8932
|
-
//
|
|
8933
|
-
|
|
8934
|
-
|
|
8935
|
-
state.selectedGoal = null;
|
|
9056
|
+
// Keep the goal selected for the centre feed; detach only the composer.
|
|
9057
|
+
state.composerDetachedGoal = bind.goal.id;
|
|
9058
|
+
state.openThreads.add(bind.goal.id);
|
|
8936
9059
|
render();
|
|
8937
9060
|
}
|
|
8938
9061
|
} else {
|
|
@@ -9218,6 +9341,14 @@ function saRowFromDigest(r, gs, ts){
|
|
|
9218
9341
|
title: saReviewTitle(g),
|
|
9219
9342
|
check: String(g?.reviewSummary?.check || r.checkLine || '').replace(/\s+/g, ' ').trim(),
|
|
9220
9343
|
goalText: String(g?.text ?? '').replace(/\s+/g, ' ').trim(),
|
|
9344
|
+
// Server supplies this fact-only ledger for new reviews. Older reviews
|
|
9345
|
+
// simply omit it, so the UI remains backward compatible during rollout.
|
|
9346
|
+
attempt: g?.reviewAttempt ?? null,
|
|
9347
|
+
unmappedChangedFiles: outcome?.unmappedChangedFiles ?? [],
|
|
9348
|
+
requirementVerification: outcome?.requirementVerification ?? null,
|
|
9349
|
+
requirementCoverage: ts.filter((t) => t.goalId === g?.id && (t.detail || t.title)).sort((a, b) => (a.num ?? 0) - (b.num ?? 0)).map((t) => ({ text: String(t.reply ? t.detail : t.title).replace(/\s+/g, ' ').trim(), taskStatus: t.status, evidenceStatus: t.requirementEvidence?.requirements?.[0]?.status ?? null })),
|
|
9350
|
+
requirementEvidence: outcome?.requirementEvidence?.requirements ?? [],
|
|
9351
|
+
requirementCoverage: ts.filter((t) => t.goalId === g?.id && (t.detail || t.title) && (!t.reply || !t.replyFrom || t.replyFrom === 'you')).sort((a, b) => (a.num ?? 0) - (b.num ?? 0)).map((t) => ({ text: String(t.reply ? t.detail : t.title).replace(/\s+/g, ' ').trim(), status: t.status })),
|
|
9221
9352
|
did, siblings, risk: g?.risk ?? null,
|
|
9222
9353
|
verificationInfraError: g?.verificationInfraError ?? r.verificationInfraError ?? null,
|
|
9223
9354
|
prNote: g?.prNote ?? null,
|
|
@@ -9723,6 +9854,44 @@ function saItemHtml(it, i){
|
|
|
9723
9854
|
const reqSec = !it.goalText ? '' : reqFold
|
|
9724
9855
|
? `<section class="rsec rreq"><div class="rsec-h">Request</div><details class="lacc" data-detkey="${gk}:req"${reqOpen ? ' open' : ''}><summary><i class="ltog">›</i><p class="rbody rask lfirst">${esc(reqPeek)}</p></summary><div class="lbody"><p class="rbody rask">${esc(it.goalText)}</p></div></details></section>`
|
|
9725
9856
|
: `<section class="rsec rreq"><div class="rsec-h">Request</div><p class="rbody rask">${esc(it.goalText)}</p></section>`;
|
|
9857
|
+
// The active attempt is intentionally a compact fact row, not a second
|
|
9858
|
+
// interpretation of the request. It makes the latest reply, files and
|
|
9859
|
+
// verification visible together while prior attempts stay in Conversation.
|
|
9860
|
+
const attempt = it.attempt;
|
|
9861
|
+
const activeText = String(attempt?.activeRequirement?.text || '').replace(/\s+/g, ' ').trim();
|
|
9862
|
+
const attemptFiles = Array.isArray(attempt?.changedFiles) ? attempt.changedFiles.filter(Boolean) : [];
|
|
9863
|
+
const attemptTests = attempt?.verification?.tests;
|
|
9864
|
+
const attemptProof = attempt?.verification?.proof;
|
|
9865
|
+
const attemptEvidence = [
|
|
9866
|
+
attemptFiles.length ? `Changed: ${attemptFiles.join(' · ')}` : '',
|
|
9867
|
+
attemptTests?.ran ? `Tests: ${attemptTests.passed ?? 0} passed${attemptTests.failed ? ` / ${attemptTests.failed} failed` : ''}` : '',
|
|
9868
|
+
attemptProof?.pass === true ? 'Proof recorded' : '',
|
|
9869
|
+
].filter(Boolean).join(' · ');
|
|
9870
|
+
const attemptSec = activeText || attemptEvidence
|
|
9871
|
+
? `<section class="rsec"><div class="rsec-h">This review</div>${activeText ? `<p class="rbody rask">${esc(activeText)}</p>` : ''}${attemptEvidence ? `<p class="rbody v">${esc(attemptEvidence)}</p>` : ''}</section>`
|
|
9872
|
+
: '';
|
|
9873
|
+
const reqVerify = it.requirementVerification;
|
|
9874
|
+
const reqVerifyParts = [
|
|
9875
|
+
reqVerify?.proof?.verified ? 'Independent proof recorded' : reqVerify?.proof ? 'Proof recorded' : '',
|
|
9876
|
+
reqVerify?.run?.url ? `Open it: ${reqVerify.run.url}` : '',
|
|
9877
|
+
Array.isArray(reqVerify?.changedFiles) && reqVerify.changedFiles.length ? `Files: ${reqVerify.changedFiles.join(' · ')}` : '',
|
|
9878
|
+
].filter(Boolean);
|
|
9879
|
+
const reqVerifySec = reqVerifyParts.length ? `<section class="rsec"><div class="rsec-h">Requirement verification</div><p class="rbody v">${esc(reqVerifyParts.join(' · '))}</p></section>` : '';
|
|
9880
|
+
const coverageRows = (it.requirementCoverage || []).filter((r) => r.text).map((r) => {
|
|
9881
|
+
const status = r.evidenceStatus || r.taskStatus;
|
|
9882
|
+
const tone = status === 'addressed' || status === 'done' ? 'ok' : status === 'partial' ? '' : 'dim';
|
|
9883
|
+
return `<p class="rbody v"><b class="${tone}">${esc(status)}</b> · ${esc(r.text)}</p>`;
|
|
9884
|
+
}).join('');
|
|
9885
|
+
const coverageSec = coverageRows ? `<section class="rsec"><div class="rsec-h">Requirement coverage</div>${coverageRows}</section>` : '';
|
|
9886
|
+
// Worker-declared evidence is displayed verbatim as an auditable label, not
|
|
9887
|
+
// promoted into a Manager verdict. Empty means the worker did not provide it.
|
|
9888
|
+
const evidenceRows = (it.requirementEvidence || []).map((entry) => {
|
|
9889
|
+
const tone = entry.status === 'addressed' ? 'ok' : entry.status === 'partial' ? '' : 'dim';
|
|
9890
|
+
return `<p class="rbody v"><b class="${tone}">${esc(entry.status)}</b> · ${esc(entry.evidence || entry.id)}</p>`;
|
|
9891
|
+
}).join('');
|
|
9892
|
+
const evidenceSec = evidenceRows ? `<section class="rsec"><div class="rsec-h">Requirement evidence</div>${evidenceRows}</section>` : '';
|
|
9893
|
+
const userCoverageRows = (it.requirementCoverage || []).filter((r) => r.text).map((r) => `<p class="rbody v"><b class="${r.status === 'done' ? 'ok' : 'dim'}">${esc(r.status)}</b> · ${esc(r.text)}</p>`).join('');
|
|
9894
|
+
const userCoverageSec = userCoverageRows ? `<section class="rsec"><div class="rsec-h">User requirements</div>${userCoverageRows}</section>` : '';
|
|
9726
9895
|
// ── Open it (Masa 2026-07-22): a plain section in the card's own typography — the same
|
|
9727
9896
|
// heading + body shape as Request and Result, not a chip in another vocabulary. The
|
|
9728
9897
|
// address the worker declared IS the control (press it → the process starts on demand →
|
|
@@ -9840,7 +10009,11 @@ function saItemHtml(it, i){
|
|
|
9840
10009
|
const diffHtml = it.ghRows
|
|
9841
10010
|
? saGhDiffRowsHtml(it.ghRows, it.diffTotal || 0, it.pr || '', gk)
|
|
9842
10011
|
: hasGitDiff ? saGhDiffHtml(d.diff, it.pr, gk) : clientDiff ? saGhDiffHtml(clientDiff, it.pr, gk) : '';
|
|
9843
|
-
const
|
|
10012
|
+
const unmapped = Array.isArray(it.unmappedChangedFiles) ? it.unmappedChangedFiles.filter(Boolean) : [];
|
|
10013
|
+
// "Unmapped" is a fact: these measured changes were not named in worker
|
|
10014
|
+
// evidence. It is intentionally not labelled unrelated or wrong.
|
|
10015
|
+
const unmappedHtml = unmapped.length ? `<p class="rbody v">Not mapped to a requirement: ${esc(unmapped.join(' · '))}</p>` : '';
|
|
10016
|
+
const changedBody = isConv ? '' : (changedDescHtml + filesListHtml + unmappedHtml + diffHtml);
|
|
9844
10017
|
// ── sensitive-area note (real: classifyChangeRisk forced the pause) ──
|
|
9845
10018
|
const risk = it.risk;
|
|
9846
10019
|
const riskHtml = risk?.shouldPause
|
|
@@ -9925,8 +10098,18 @@ function saItemHtml(it, i){
|
|
|
9925
10098
|
// interrupted goal is amber+resumable, everything else stopped is a red error.
|
|
9926
10099
|
const attnInt = gstatus === 'interrupted' || Boolean(it.interruptedTask);
|
|
9927
10100
|
const attnLabel = attnInt ? 'Interrupted — resume available' : gstatus === 'blocked' ? 'Blocked' : 'Failed';
|
|
10101
|
+
const liveReply = [...state.tasks].reverse().find((t) => t.goalId === it.goalId && t.reply);
|
|
10102
|
+
const liveWorkLabel = liveReply?.status === 'queued'
|
|
10103
|
+
? 'Queued · To Do'
|
|
10104
|
+
: liveReply?.status === 'running'
|
|
10105
|
+
? 'Working on it · Doing'
|
|
10106
|
+
: it.goal?.status === 'review'
|
|
10107
|
+
? 'Ready for review'
|
|
10108
|
+
: it.goal?.status === 'failed' || it.goal?.status === 'partial'
|
|
10109
|
+
? 'Needs attention'
|
|
10110
|
+
: 'Working on it';
|
|
9928
10111
|
const rst = working
|
|
9929
|
-
?
|
|
10112
|
+
? `<span class="rst working"><span class="rdot"></span>${liveWorkLabel}</span>`
|
|
9930
10113
|
: attn
|
|
9931
10114
|
? `<span class="rst ${attnInt ? 'int' : 'err'}"><span class="rdot"></span>${attnLabel}</span>`
|
|
9932
10115
|
: '<span class="rst"><span class="rdot"></span>In review</span>';
|
|
@@ -9935,6 +10118,11 @@ function saItemHtml(it, i){
|
|
|
9935
10118
|
<h2 class="rtitle">${esc(it.title)}</h2>
|
|
9936
10119
|
${meta}
|
|
9937
10120
|
${reqSec}
|
|
10121
|
+
${attemptSec}
|
|
10122
|
+
${coverageSec}
|
|
10123
|
+
${evidenceSec}
|
|
10124
|
+
${reqVerifySec}
|
|
10125
|
+
${userCoverageSec}
|
|
9938
10126
|
${S('Open it', openBody)}
|
|
9939
10127
|
${S('Run state', interruptedBody)}
|
|
9940
10128
|
${S('PR', prRepairBody)}
|
|
@@ -10095,7 +10283,22 @@ function saRender(){
|
|
|
10095
10283
|
function saSync(){
|
|
10096
10284
|
if (!SA.open) return;
|
|
10097
10285
|
const ae = document.activeElement;
|
|
10098
|
-
|
|
10286
|
+
// Do not erase text while the reviewer is typing. Once Send clears the box,
|
|
10287
|
+
// focus alone must not freeze To Do / Doing / Review status updates.
|
|
10288
|
+
if (ae && $('seeall').contains(ae) && ae.tagName === 'INPUT' && ae.value) return;
|
|
10289
|
+
// Working cards are intentionally absent from the Review ledger rows after the
|
|
10290
|
+
// server moves them to To Do / Doing. Keep their held dialog snapshot wired to
|
|
10291
|
+
// live goal/task state so the status changes in place instead of disappearing.
|
|
10292
|
+
for (const ex of SA.items.filter((x) => x.st === 3)) {
|
|
10293
|
+
const liveGoal = state.goals.find((g) => g.id === ex.goalId);
|
|
10294
|
+
if (liveGoal) ex.goal = liveGoal;
|
|
10295
|
+
const activeReply = state.tasks.some((t) => t.goalId === ex.goalId && t.reply && ['queued', 'running'].includes(t.status));
|
|
10296
|
+
if (liveGoal?.status === 'review' && !activeReply) {
|
|
10297
|
+
ex.st = 0;
|
|
10298
|
+
ex.rstatus = null;
|
|
10299
|
+
ex.inflight = false;
|
|
10300
|
+
}
|
|
10301
|
+
}
|
|
10099
10302
|
const rows = saLedgerRows();
|
|
10100
10303
|
for (const row of rows) {
|
|
10101
10304
|
const ex = SA.items.find((x) => x.goalId === row.goalId);
|
|
@@ -10193,42 +10396,24 @@ async function saReplyCall(it, text, outcome){
|
|
|
10193
10396
|
if (!r?.ok) throw new Error('reply failed');
|
|
10194
10397
|
return r.json().catch(() => null);
|
|
10195
10398
|
}
|
|
10196
|
-
// Once feedback becomes rework,
|
|
10197
|
-
//
|
|
10198
|
-
//
|
|
10199
|
-
function
|
|
10399
|
+
// Once feedback becomes rework, keep the card as a live progress surface until the
|
|
10400
|
+
// reviewer closes it. The lane behind the dialog still moves immediately to To Do / Doing,
|
|
10401
|
+
// and the centre conversation follows the same goal; only the Review card snapshot is held.
|
|
10402
|
+
function saKeepFeedbackLive(it, body){
|
|
10200
10403
|
const goal = body?.goal;
|
|
10201
10404
|
const task = body?.task;
|
|
10202
10405
|
if (goal?.id) upsert(state.goals, goal);
|
|
10203
10406
|
if (task?.id) upsert(state.tasks, task);
|
|
10204
|
-
|
|
10205
|
-
|
|
10206
|
-
|
|
10207
|
-
|
|
10208
|
-
|
|
10209
|
-
|
|
10210
|
-
|
|
10211
|
-
|
|
10212
|
-
const cardEl = saCardEl(it.goalId);
|
|
10213
|
-
const priorScrollTop = bodyEl?.scrollTop ?? 0;
|
|
10214
|
-
const removedHeight = cardEl ? cardEl.getBoundingClientRect().height : 0;
|
|
10215
|
-
const cardAboveViewport = !!(cardEl && bodyEl && cardEl.getBoundingClientRect().bottom <= bodyEl.getBoundingClientRect().top + 1);
|
|
10216
|
-
if (at >= 0) SA.items.splice(at, 1);
|
|
10407
|
+
it.goal = goal ?? state.goals.find((g) => g.id === it.goalId) ?? it.goal;
|
|
10408
|
+
it.st = 3;
|
|
10409
|
+
it.inflight = false;
|
|
10410
|
+
it.rstatus = 'working';
|
|
10411
|
+
SA.cur = it.goalId;
|
|
10412
|
+
state.selectedGoal = it.goalId;
|
|
10413
|
+
state.composerDetachedGoal = it.goalId;
|
|
10414
|
+
state.openThreads.add(it.goalId);
|
|
10217
10415
|
render();
|
|
10218
|
-
|
|
10219
|
-
SA.cur = next?.goalId ?? SA.items[0]?.goalId ?? null;
|
|
10220
|
-
if (cardAboveViewport) {
|
|
10221
|
-
// Keep reading where we were: compensate for the removed card's height instead of
|
|
10222
|
-
// forcing a scroll-to-top of whatever is now "current".
|
|
10223
|
-
SA.focusGid = null;
|
|
10224
|
-
saRender();
|
|
10225
|
-
const newBody = document.querySelector('#seeall .sa-body');
|
|
10226
|
-
if (newBody) newBody.scrollTop = Math.max(0, priorScrollTop - removedHeight);
|
|
10227
|
-
} else {
|
|
10228
|
-
SA.focusGid = SA.cur;
|
|
10229
|
-
SA.focusScrolled = false;
|
|
10230
|
-
saRender();
|
|
10231
|
-
}
|
|
10416
|
+
saPaintCard(it.goalId);
|
|
10232
10417
|
}
|
|
10233
10418
|
function saCardEl(goalId){ return document.querySelector(`#seeall .sa-item[data-gid="${goalId}"]`); }
|
|
10234
10419
|
// Flip one card to its "Working on it" head without redrawing anything else (決定2).
|
|
@@ -10237,7 +10422,13 @@ function saFlipWorking(goalId){
|
|
|
10237
10422
|
if (!card) return;
|
|
10238
10423
|
card.classList.add('working');
|
|
10239
10424
|
const rst = card.querySelector('.rhead .rst');
|
|
10240
|
-
|
|
10425
|
+
const it = SA.items.find((x) => x.goalId === goalId);
|
|
10426
|
+
const liveReply = [...state.tasks].reverse().find((t) => t.goalId === goalId && t.reply);
|
|
10427
|
+
const label = liveReply?.status === 'queued' ? 'Queued · To Do'
|
|
10428
|
+
: liveReply?.status === 'running' ? 'Working on it · Doing'
|
|
10429
|
+
: it?.goal?.status === 'review' ? 'Ready for review'
|
|
10430
|
+
: 'Working on it';
|
|
10431
|
+
if (rst) { rst.className = 'rst working'; rst.innerHTML = `<span class="rdot"></span>${label}`; }
|
|
10241
10432
|
}
|
|
10242
10433
|
// Repaint one card's reply thread IN PLACE from its client state — no saRender, so nothing
|
|
10243
10434
|
// above the box moves. Header is flipped to "Working on it" iff the card is in that state,
|
|
@@ -10331,7 +10522,7 @@ async function saSendReply(it, i, text){
|
|
|
10331
10522
|
// A change request has left Review. The response is the source of truth for whether it
|
|
10332
10523
|
// is queued (To Do) or already starting (Doing); reveal that transition immediately.
|
|
10333
10524
|
if (body?.goal && body.goal.status !== 'review') {
|
|
10334
|
-
|
|
10525
|
+
saKeepFeedbackLive(it, body);
|
|
10335
10526
|
return;
|
|
10336
10527
|
}
|
|
10337
10528
|
// split keeps the original goal in Review; only the newly-created ticket moves away.
|
|
@@ -11054,7 +11245,7 @@ $('filein').addEventListener('change', () => {
|
|
|
11054
11245
|
$('filein').value = '';
|
|
11055
11246
|
});
|
|
11056
11247
|
// Feature A: ✕ on the "Replying to #NNN" chip detaches → back to new-goal mode.
|
|
11057
|
-
$('replyBindX').onclick = () => { state.
|
|
11248
|
+
$('replyBindX').onclick = () => { state.composerDetachedGoal = state.selectedGoal; render(); };
|
|
11058
11249
|
const IS_MOBILE = () => window.matchMedia('(max-width: 760px)').matches;
|
|
11059
11250
|
// Phones are chat-first: the Tasks sheet starts FOLDED so the first view is the
|
|
11060
11251
|
// conversation + composer (the sheet is an 88vw overlay — default-open buried
|
|
@@ -11696,7 +11887,7 @@ function gearHistHTML(){
|
|
|
11696
11887
|
};
|
|
11697
11888
|
// History = everything you've sent, newest first (not only settled goals —
|
|
11698
11889
|
// that felt empty). Each row gets a status glyph; unknown states fall back.
|
|
11699
|
-
const MARK = { done: ['ok', '✓'], reverted: ['no', '↩'], skipped: ['ar', '—'], failed: ['no', '✕'], interrupted: ['ar', '⏸'], review: ['rv', '●'], running: ['run', '…'], planning: ['run', '…'], stacked: ['ar', '·'], needsInput: ['rv', '?'] };
|
|
11890
|
+
const MARK = { done: ['ok', '✓'], reverted: ['no', '↩'], skipped: ['ar', '—'], failed: ['no', '✕'], interrupted: ['ar', '⏸'], review: ['rv', '●'], running: ['run', '…'], planning: ['run', '…'], stacked: ['ar', '·'], needsInput: ['rv', '?'], needsApproval: ['rv', '?'] };
|
|
11700
11891
|
const evs = (state.goals || [])
|
|
11701
11892
|
.filter((g) => g.projectId === state.active)
|
|
11702
11893
|
.map((g) => ({ g, ts: goalTs(g) })).sort((a, b) => b.ts - a.ts);
|
|
@@ -11747,15 +11938,23 @@ function gearSetHTML(){
|
|
|
11747
11938
|
? acct + `<div class="srow"><div class="sl">Plan</div><span class="bx-pdot"><i></i>Paid</span></div>
|
|
11748
11939
|
<div class="srow"><div class="sl">Billing<span class="sd">manage or cancel in Stripe</span></div><button class="bx-cchip" data-gportal="1">Manage</button></div>`
|
|
11749
11940
|
: acct + freeUsage;
|
|
11941
|
+
const st = state.stability;
|
|
11942
|
+
const stability = st ? `<div class="srow"><div class="sl">Verification health<span class="sd">Last 24h · implementation ${st.implementationFailures} · environment ${st.verificationInfrastructure} · inconclusive ${st.timeoutsOrInconclusive} · worker holds ${st.workerHolds}</span></div><button class="optchip" data-gstability="1">Refresh</button></div>` : `<div class="srow"><div class="sl">Verification health<span class="sd">Loading…</span></div></div>`;
|
|
11750
11943
|
return `
|
|
11751
11944
|
<div class="srow"><div class="sl">Language<span class="sd">Review checklist language for this project</span></div>
|
|
11752
11945
|
<button class="optchip${lang === 'auto' ? ' on' : ''}" data-gslang="auto">Auto</button>
|
|
11753
11946
|
<button class="optchip${lang === 'en' ? ' on' : ''}" data-gslang="en">EN</button>
|
|
11754
11947
|
<button class="optchip${lang === 'ja' ? ' on' : ''}" data-gslang="ja">JA</button></div>
|
|
11755
11948
|
${billingRow}
|
|
11949
|
+
${stability}
|
|
11756
11950
|
${gearRulesHTML()}
|
|
11757
11951
|
<div class="srow dis"><div class="sl">Data folder<span class="sd">Runs are stored in the engine's chat-runs folder on the host</span></div></div>`;
|
|
11758
11952
|
}
|
|
11953
|
+
async function loadStability(){
|
|
11954
|
+
try { const r = await fetch(withKey('/api/stability')); state.stability = r.ok ? await r.json() : null; }
|
|
11955
|
+
catch { state.stability = null; }
|
|
11956
|
+
if ($('gearPop') && custTab === 'settings') buildGearPop();
|
|
11957
|
+
}
|
|
11759
11958
|
// ONE-CONVERSATION §1.5a: the project's working rules — the file(s) the connected
|
|
11760
11959
|
// AI actually reads (Claude Code → CLAUDE.md, Codex → AGENTS.md). Read-only: Galda
|
|
11761
11960
|
// does NOT own these rules, it only surfaces them (editing is done in an editor or
|
|
@@ -11844,7 +12043,10 @@ function buildGearPop(){
|
|
|
11844
12043
|
};
|
|
11845
12044
|
const rulesBtn = pop.querySelector('[data-grules]');
|
|
11846
12045
|
if (rulesBtn) rulesBtn.onclick = () => { state.rulesOpen = !state.rulesOpen; buildGearPop(); };
|
|
12046
|
+
const stabilityBtn = pop.querySelector('[data-gstability]');
|
|
12047
|
+
if (stabilityBtn) stabilityBtn.onclick = () => { state.stability = null; buildGearPop(); loadStability(); };
|
|
11847
12048
|
loadProjectRules();
|
|
12049
|
+
if (!state.stability) loadStability();
|
|
11848
12050
|
return;
|
|
11849
12051
|
}
|
|
11850
12052
|
if (custTab === 'mcp') {
|