@galda/cli 0.10.40 → 0.10.42
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 +698 -68
- package/engine/lib.mjs +311 -87
- package/engine/server.mjs +392 -73
- package/engine/shot.mjs +16 -1
- package/package.json +1 -1
package/app/index.html
CHANGED
|
@@ -267,6 +267,57 @@
|
|
|
267
267
|
.appmenu{position:absolute;left:0;bottom:calc(100% + 6px);min-width:150px;background:var(--surface);border:1px solid var(--hair2);
|
|
268
268
|
border-radius:10px;padding:4px;display:none;flex-direction:column;gap:1px;box-shadow:0 10px 28px rgba(0,0,0,.5);z-index:50}
|
|
269
269
|
.appmenu.show{display:flex}
|
|
270
|
+
/* Context row — the Claude app's shape: separate chips above the box. The chips are
|
|
271
|
+
the composer's OWN control grammar (.appbtn: 26px tall, r8, 1px --hair, transparent,
|
|
272
|
+
11.5px, --ink2), because one input box must not have two chip languages above and
|
|
273
|
+
below it (§1.5 器を増やさない/§5.5 面の処理はパネル全体に均一). Strength comes from
|
|
274
|
+
the TEXT — only the repo name is 600/--ink — never from a louder container.
|
|
275
|
+
CDO handoff 2026-07-23 §A1. `var(--chipbg)` is gone on purpose: it is declared inside
|
|
276
|
+
.gearpop{}, so out here it was invalid at computed-value time and fell back to
|
|
277
|
+
transparent — measured rgba(0,0,0,0). The chips were never filled to begin with. */
|
|
278
|
+
.ctxbar{display:flex;align-items:center;gap:6px;padding:0 2px 7px;background:none;border:0}
|
|
279
|
+
.ctxchip{display:inline-flex;align-items:center;height:26px;padding:0;gap:0;border-radius:8px;
|
|
280
|
+
border:1px solid var(--hair);background:transparent;position:relative;min-width:0}
|
|
281
|
+
.ctxsel{position:relative}
|
|
282
|
+
.ctxseg{display:inline-flex;align-items:center;gap:6px;height:24px;padding:0 8px;border-radius:7px;
|
|
283
|
+
color:var(--ink2);font-size:11.5px;cursor:pointer;transition:color .12s;white-space:nowrap;
|
|
284
|
+
min-width:0;border:0;background:transparent}
|
|
285
|
+
.ctxseg:hover{color:var(--ink)} /* glyph colour only — no surface (§5.5) */
|
|
286
|
+
.ctxseg svg{width:13px;height:13px;flex:0 0 13px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
|
|
287
|
+
.ctxseg b{font-weight:600;font-size:11.5px;color:var(--ink);overflow:hidden;text-overflow:ellipsis;max-width:300px}
|
|
288
|
+
.ctxdiv{width:1px;align-self:stretch;margin:5px 0;background:var(--hair);flex:0 0 1px}
|
|
289
|
+
/* worktree — Masa 2026-07-23: 「claude code は ✓ が外れるだけなのでそれでいきたい。機能も
|
|
290
|
+
claude code と同じにしたい」。OFF just loses its tick and sinks; no amber, no note, no
|
|
291
|
+
dialog. ON is --ink, never --green: in this product green means "it passed" (PR green,
|
|
292
|
+
tests green, Token green), and a settings checkbox borrowing it makes that green cheap.
|
|
293
|
+
The tick is knocked out in --panel (the surface it sits on), NOT --invink. */
|
|
294
|
+
.ctxwt{font-size:11.5px;color:var(--ink2)}
|
|
295
|
+
.ctxwt .ctxbox{width:11px;height:11px;flex:0 0 11px;border-radius:3px;border:1px solid var(--ink3);
|
|
296
|
+
display:inline-block;position:relative;transition:background .12s,border-color .12s}
|
|
297
|
+
.ctxwt[aria-checked="true"]{color:var(--ink2)}
|
|
298
|
+
.ctxwt[aria-checked="true"] .ctxbox{background:var(--ink);border-color:var(--ink)}
|
|
299
|
+
.ctxwt[aria-checked="true"] .ctxbox::after{content:"";position:absolute;left:3px;top:0;width:3px;height:7px;
|
|
300
|
+
border:solid var(--panel);border-width:0 1.6px 1.6px 0;transform:rotate(42deg)}
|
|
301
|
+
.ctxmenu{min-width:230px;max-width:420px}
|
|
302
|
+
/* The folder list is galda's own screen (Claude has none — it opens ONE folder; galda
|
|
303
|
+
moves between repos). The one thing that actually goes wrong is that names alone look
|
|
304
|
+
identical (~/agent-manager vs ~/am-wt-kanban/agent-manager), so every row carries the
|
|
305
|
+
path underneath. Same container as every other menu — .appmenu/.appopt (§A2). */
|
|
306
|
+
.ctxmenu .appopt{height:auto;flex-direction:column;align-items:stretch;gap:1px;padding:6px 8px;
|
|
307
|
+
font-family:var(--ui);font-size:12px}
|
|
308
|
+
.ctxmenu .appopt .nm{color:var(--ink);font-size:12px;line-height:1.35}
|
|
309
|
+
.ctxmenu .appopt:not(.active) .nm{color:var(--ink2)}
|
|
310
|
+
/* direction:rtl keeps the END of the path (where repos differ) and drops the head.
|
|
311
|
+
The row text starts with a LRM — an RLO would render the path itself reversed. */
|
|
312
|
+
.ctxmenu .appopt .pt{font-family:var(--mono);font-size:10px;color:var(--ink3);line-height:1.4;
|
|
313
|
+
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left;unicode-bidi:plaintext}
|
|
314
|
+
.ctxmenu .ctxpath{display:flex;gap:5px;padding:5px 4px 2px;border-top:1px solid var(--hair);margin-top:3px}
|
|
315
|
+
.ctxmenu .ctxpath input{flex:1;min-width:0;height:26px;padding:0 8px;border-radius:6px;border:1px solid var(--hair2);
|
|
316
|
+
background:var(--chipbg);color:var(--ink);font-family:var(--mono);font-size:11px;outline:none}
|
|
317
|
+
.ctxmenu .ctxpath input:focus{border-color:var(--blue)}
|
|
318
|
+
.ctxmenu .ctxmsg{padding:2px 5px 3px;font-size:11px;color:var(--ink3)}
|
|
319
|
+
.ctxmenu .ctxmsg:empty{display:none}
|
|
320
|
+
.ctxmenu .ctxmsg.bad{color:var(--danger)}
|
|
270
321
|
.appopt{display:flex;align-items:center;gap:7px;height:28px;padding:0 8px;border:0;border-radius:6px;background:transparent;
|
|
271
322
|
color:var(--ink2);font:inherit;font-size:12px;cursor:pointer;text-align:left;width:100%}
|
|
272
323
|
.appopt:hover{background:rgba(var(--tint),.06);color:var(--ink)}
|
|
@@ -408,6 +459,14 @@
|
|
|
408
459
|
.qansrow{display:flex;gap:7px;align-items:center;margin-top:8px}
|
|
409
460
|
.qansrow input{flex:1;background:var(--glass);border:1px solid var(--hair2);border-radius:8px;color:var(--ink);font-size:12.5px;padding:6px 10px;outline:none}
|
|
410
461
|
.qsub{font-size:10.5px;color:var(--ink3);margin-top:7px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
462
|
+
/* The one line under a reply box: what the AI asked back, or the answer it gave.
|
|
463
|
+
Same part as .qsub, but an answer is a sentence — it must wrap, not ellipsis. */
|
|
464
|
+
/* The reply box's own short thread (CDO HANDOFF-REPLY-CONVERSATION §2/§5). It uses
|
|
465
|
+
the card's .txr You/Claude rows, so it needs .qsub's single-line truncation OFF —
|
|
466
|
+
scoped here, since every other .qsub stays a one-liner. */
|
|
467
|
+
.replynote{white-space:normal;overflow:visible;margin-top:14px;padding-top:12px;border-top:1px solid var(--hair)}
|
|
468
|
+
.replynote .qchips{margin-top:6px}
|
|
469
|
+
.replynote .txr.note .m{color:var(--ink3)}
|
|
411
470
|
.ob-unsent{color:rgb(var(--danger-rgb));cursor:pointer}
|
|
412
471
|
[data-oresend]{cursor:pointer}
|
|
413
472
|
.chip2{font-family:var(--mono);font-size:9.5px;letter-spacing:.05em;padding:2px 7px;border-radius:6px;border:1px solid var(--hair2);color:var(--ink3);flex:0 0 auto}
|
|
@@ -1159,6 +1218,14 @@
|
|
|
1159
1218
|
.gearpop .srow.dis{opacity:.38;pointer-events:none}
|
|
1160
1219
|
.gearpop .srow .sl{flex:1;min-width:0;font-size:12.5px;color:var(--ink)}
|
|
1161
1220
|
.gearpop .srow .sd{display:block;font-size:10.5px;color:var(--ink3);margin-top:2px}
|
|
1221
|
+
/* Project rules read-only view (§1.5a): a quiet framed panel under its row —
|
|
1222
|
+
mono filename label + the file body, scrollable. Reuses --canvas/--hair so it
|
|
1223
|
+
matches the MCP box; no new surface invented. */
|
|
1224
|
+
.gearpop .rulesview{margin:2px 0 9px;display:flex;flex-direction:column;gap:8px}
|
|
1225
|
+
.gearpop .rulesfile{background:var(--canvas);border-radius:8px;overflow:hidden}
|
|
1226
|
+
.gearpop .rulesname{font:600 9px/1 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--ink3);padding:8px 10px 0}
|
|
1227
|
+
.gearpop .rulestext{margin:6px 0 0;padding:0 10px 10px;max-height:220px;overflow:auto;font:400 11px/1.6 var(--mono);color:var(--ink2);white-space:pre-wrap;word-break:break-word}
|
|
1228
|
+
.gearpop .rulesempty{color:var(--ink3);font-style:italic}
|
|
1162
1229
|
/* MCP tab: live status dot + connect command + quiet explanation */
|
|
1163
1230
|
.gearpop .mcpstat{display:flex;align-items:center;gap:8px;font:600 11px/1 var(--ui);color:var(--ink);padding:2px 0 10px}
|
|
1164
1231
|
.gearpop .mcpstat i{width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 6px rgba(var(--green-rgb),.5)}
|
|
@@ -2133,6 +2200,23 @@
|
|
|
2133
2200
|
/* Manager's own progress notes inside the implementation log: commentary, not results.
|
|
2134
2201
|
Green would read as "passed" (§5.5 UIクロームは薄く・優しく・主張しない). */
|
|
2135
2202
|
.redesign .txr.note .m{font-family:var(--mono);font-size:12px;color:var(--ink3)}
|
|
2203
|
+
/* Conversation (ONE-CONVERSATION §1.1): the same transcript rows, so several
|
|
2204
|
+
entrances read as ONE column — no second box, no bubbles (§1.5-1 器を増やさない).
|
|
2205
|
+
Where a line was said is a hairline-quiet label, never a chip: it is context for
|
|
2206
|
+
the sentence, not a category of its own (§5.5 薄い要素も見えるが騒がない). */
|
|
2207
|
+
/* The entrance is CONTEXT for a sentence, not a category of it — so it is not a column
|
|
2208
|
+
(CDO handoff 2026-07-23 §B1). As a column it repeated 'chat' three times in five rows
|
|
2209
|
+
and pushed the body 116px right, so the same card had two left edges for the same kind
|
|
2210
|
+
of row. At the end of the line, only when it changed, the body lines up with Activity
|
|
2211
|
+
and the one thing worth knowing survives. */
|
|
2212
|
+
.redesign .txr .cvia{flex:0 0 auto;font-size:11px;color:var(--ink3);white-space:nowrap;padding-top:1px}
|
|
2213
|
+
/* "+N more" borrows the log's own quiet row (mono, --ink3) rather than inventing a
|
|
2214
|
+
control: nothing was summarised away, so pressing it only stops hiding (§B3). */
|
|
2215
|
+
.redesign .txr.convmore{cursor:pointer}
|
|
2216
|
+
.redesign .txr.convmore .m{font-family:var(--mono);font-size:12px;color:var(--ink3)}
|
|
2217
|
+
.redesign .txr.convmore:hover .m{color:var(--ink2)}
|
|
2218
|
+
.redesign .rconv .lbody .rtx{max-width:760px}
|
|
2219
|
+
.redesign .txr.mgr .m{color:var(--ink3)}
|
|
2136
2220
|
/* Discussion (review back-and-forth) folded at the bottom of Activity's body */
|
|
2137
2221
|
.redesign .lbody .rthread{display:flex;flex-direction:column;gap:16px;margin:0}
|
|
2138
2222
|
.redesign .lbody .rmsg .who{font-size:12px;font-weight:600;color:var(--ink);margin-bottom:4px}
|
|
@@ -2800,6 +2884,26 @@
|
|
|
2800
2884
|
<div class="errbar" id="errbar"></div>
|
|
2801
2885
|
<div class="composer-wrap" id="composerWrap">
|
|
2802
2886
|
<div class="queue" id="queue"></div>
|
|
2887
|
+
<!-- Context row (Masa 2026-07-22: 「claude の表記に合わせて」). The Claude app
|
|
2888
|
+
puts separate rounded chips above the box — repo · branch|worktree · add —
|
|
2889
|
+
rather than one continuous strip, and the words are its words. `Local` is
|
|
2890
|
+
deliberately absent: Claude offers it because it also runs in the cloud,
|
|
2891
|
+
and a chip that cannot be switched is decoration (§1.5-3). -->
|
|
2892
|
+
<div class="ctxbar" id="ctxbar">
|
|
2893
|
+
<div class="ctxsel ctxchip" id="ctxdirsel">
|
|
2894
|
+
<button type="button" class="ctxseg" id="ctxdirbtn" title="Folder this project works in">
|
|
2895
|
+
<svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z"/></svg><b id="ctxdirnow"></b></button>
|
|
2896
|
+
<div class="appmenu ctxmenu" id="ctxdirmenu"></div>
|
|
2897
|
+
</div>
|
|
2898
|
+
<div class="ctxchip ctxbrgrp" id="ctxbrgrp">
|
|
2899
|
+
<span class="ctxseg ctxbranch" id="ctxbranch" title="Branch this folder is on">
|
|
2900
|
+
<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>
|
|
2901
|
+
<span class="ctxdiv"></span>
|
|
2902
|
+
<button type="button" class="ctxseg ctxwt" id="ctxwt" role="checkbox" aria-checked="true"
|
|
2903
|
+
title="On: each goal works in its own copy, so this folder is never touched. Off: the work happens right here.">
|
|
2904
|
+
<i class="ctxbox"></i>worktree</button>
|
|
2905
|
+
</div>
|
|
2906
|
+
</div>
|
|
2803
2907
|
<!-- Conversation-first composer (SLICE 2, docs/HANDOFF-ONBOARDING-conversational.md):
|
|
2804
2908
|
a chat/help/settings message gets a quiet reply HERE instead of
|
|
2805
2909
|
becoming a To-Do. Reuses the .peraanswer surface; hidden until a
|
|
@@ -3180,7 +3284,7 @@ const savedTheme = (() => {
|
|
|
3180
3284
|
if (v !== s) localStorage.setItem('theme', v);
|
|
3181
3285
|
return v;
|
|
3182
3286
|
})();
|
|
3183
|
-
const state = { projects: [], goals: [], tasks: [], act: {}, actAt: {}, todos: {}, chatTurns: {}, active: 'default', editing: null, attach: [], connectCommand: '', models: ['sonnet'], agentModels: null, agentEfforts: null, agents: ['claude-code', 'codex'], expanded: new Set(), openThreads: new Set(), queueOrder: {}, externalActivity: [], selectedGoal: null, workflowColumns: {}, wfDraft: null, reviewDefinitions: {}, rdDraft: null, connectedApp: localStorage.getItem('sel:agent') || 'claude-code', reviewOpenGoal: null, reviewApprovals: {}, goalDetailOpen: null, qreplyOpen: new Set(), logCollapsed: localStorage.getItem('logCollapsed') !== '0', logEntries: [], layout: localStorage.getItem('layout') || 'flagship', theme: savedTheme, boardskin: localStorage.getItem('boardskin') || 'auto', summaryPattern: Number(localStorage.getItem('summaryPattern')) || 3, peraSpot: 0, skill: null, skillsCache: null,
|
|
3287
|
+
const state = { repos: null, repoHome: '', /* GET /api/repos — the folders a project can point at (Context Bar) */ projects: [], goals: [], tasks: [], act: {}, actAt: {}, todos: {}, chatTurns: {}, active: 'default', editing: null, attach: [], connectCommand: '', models: ['sonnet'], agentModels: null, agentEfforts: null, agents: ['claude-code', 'codex'], expanded: new Set(), openThreads: new Set(), queueOrder: {}, externalActivity: [], selectedGoal: null, workflowColumns: {}, wfDraft: null, reviewDefinitions: {}, rdDraft: null, connectedApp: localStorage.getItem('sel:agent') || 'claude-code', reviewOpenGoal: null, reviewApprovals: {}, goalDetailOpen: null, qreplyOpen: new Set(), logCollapsed: localStorage.getItem('logCollapsed') !== '0', logEntries: [], layout: localStorage.getItem('layout') || 'flagship', theme: savedTheme, boardskin: localStorage.getItem('boardskin') || 'auto', summaryPattern: Number(localStorage.getItem('summaryPattern')) || 3, peraSpot: 0, skill: null, skillsCache: null, projectRules: {}, rulesOpen: false,
|
|
3184
3288
|
// per-message send overrides set via slash-commands (reset after each send,
|
|
3185
3289
|
// except model which is sticky in localStorage). palette = open command list.
|
|
3186
3290
|
msg: { mode: null, model: null, effort: null, skill: null, later: false, review: false },
|
|
@@ -3370,9 +3474,12 @@ function prioBadge(t){
|
|
|
3370
3474
|
|
|
3371
3475
|
// Mirrors engine/lib.mjs DEFAULT_REVIEW_DEFINITION — kept in sync manually,
|
|
3372
3476
|
// see the comment on DEFAULT_WORKFLOW_COLUMNS above.
|
|
3477
|
+
// `requirePR` and `prSections` are deliberately absent: the engine stores them
|
|
3478
|
+
// but no code has ever read them, so the switches that wrote them are gone
|
|
3479
|
+
// (2026-07-22). The engine-side fields go in a follow-up; nothing here reads
|
|
3480
|
+
// them in the meantime.
|
|
3373
3481
|
const DEFAULT_REVIEW_DEFINITION = {
|
|
3374
|
-
|
|
3375
|
-
prSections: { summary: true, whatChanged: true, howToReview: true, tests: true, screenshots: true, risk: true },
|
|
3482
|
+
requireVerifyPass: false, description: '', defaultWantsPR: false, language: 'auto',
|
|
3376
3483
|
reviewCard: { screenshots: true, beforeAfter: false },
|
|
3377
3484
|
};
|
|
3378
3485
|
// Shown (faint) under Review when no definition is set yet, so it's obvious the
|
|
@@ -4602,9 +4709,14 @@ function renderTasks(){
|
|
|
4602
4709
|
// the rework task ("reworking: <goal>").
|
|
4603
4710
|
const sendQReply = async (gid, inp) => {
|
|
4604
4711
|
const text = inp.value.trim(); if (!text) return;
|
|
4605
|
-
|
|
4606
|
-
|
|
4607
|
-
|
|
4712
|
+
try {
|
|
4713
|
+
const body = await replyToGoal(gid, text, `[data-qreplyinput="${gid}"]`);
|
|
4714
|
+
if (!body) return; // asked, and nobody chose — leave the words in the box
|
|
4715
|
+
inp.value = '';
|
|
4716
|
+
// A question keeps the row open: the answer lands under this very box.
|
|
4717
|
+
if (body.outcome !== 'answer') state.qreplyOpen.delete(Number(gid));
|
|
4718
|
+
await refresh();
|
|
4719
|
+
} catch { showErr('送信に失敗しました'); }
|
|
4608
4720
|
};
|
|
4609
4721
|
for (const b of $('tasklist').querySelectorAll('[data-qreplysend]')) {
|
|
4610
4722
|
b.onclick = (e) => { e.stopPropagation(); sendQReply(b.dataset.qreplysend, $('tasklist').querySelector(`[data-qreplyinput="${b.dataset.qreplysend}"]`)); };
|
|
@@ -4893,13 +5005,12 @@ function renderAttach(){
|
|
|
4893
5005
|
function openWfEditor(){
|
|
4894
5006
|
state.wfDraft = columnsFor(state.active).map((c) => ({ ...c }));
|
|
4895
5007
|
const def = reviewDefinitionFor(state.active);
|
|
4896
|
-
//
|
|
4897
|
-
//
|
|
5008
|
+
// reviewCard is a nested object — a shallow `{ ...def }` would share it by
|
|
5009
|
+
// reference, so toggling one checkbox here would mutate
|
|
4898
5010
|
// DEFAULT_REVIEW_DEFINITION (or another project's cached definition)
|
|
4899
|
-
// instead of just this draft. Copy
|
|
5011
|
+
// instead of just this draft. Copy it one level deeper.
|
|
4900
5012
|
state.rdDraft = {
|
|
4901
5013
|
...def,
|
|
4902
|
-
prSections: { ...DEFAULT_REVIEW_DEFINITION.prSections, ...(def.prSections ?? {}) },
|
|
4903
5014
|
reviewCard: reviewCardConfigFor(state.active),
|
|
4904
5015
|
};
|
|
4905
5016
|
$('wferr').style.display = 'none';
|
|
@@ -4912,35 +5023,12 @@ function renderRdEditor(){
|
|
|
4912
5023
|
<label class="rdrow"><input type="checkbox" id="rdDefaultWantsPR"${state.rdDraft.defaultWantsPR ? ' checked' : ''}>
|
|
4913
5024
|
<span><span class="rdlabel">Open a GitHub pull request by default</span>
|
|
4914
5025
|
<span class="rdhint">New goals left on "Auto" open a PR unless their text says otherwise. Per-goal PR/No PR always overrides this.</span></span></label>
|
|
4915
|
-
<label class="rdrow"><input type="checkbox" id="rdRequirePR"${state.rdDraft.requirePR ? ' checked' : ''}>
|
|
4916
|
-
<span><span class="rdlabel">Requires a pull request</span>
|
|
4917
|
-
<span class="rdhint">A goal that asked for a PR always lands in Review, even if PR creation failed.</span></span></label>
|
|
4918
5026
|
<label class="rdrow"><input type="checkbox" id="rdRequireVerify"${state.rdDraft.requireVerifyPass ? ' checked' : ''}>
|
|
4919
5027
|
<span><span class="rdlabel">Requires an independent verify pass</span>
|
|
4920
5028
|
<span class="rdhint">Any task whose headless-browser check didn't pass (or never ran) forces Review too.</span></span></label>
|
|
4921
5029
|
<label class="rdrow rddesc"><span><span class="rdlabel">Review instructions</span>
|
|
4922
|
-
<span class="rdhint">
|
|
4923
|
-
<textarea class="rdtext" id="rdDescription" maxlength="500" rows="2" placeholder="
|
|
4924
|
-
|
|
4925
|
-
<div class="rdsub">PR本文に含める項目 / PR body sections</div>
|
|
4926
|
-
<label class="rdrow"><input type="checkbox" id="rdPrSummary"${state.rdDraft.prSections.summary ? ' checked' : ''}>
|
|
4927
|
-
<span><span class="rdlabel">Summary</span>
|
|
4928
|
-
<span class="rdhint">ゴールの目的(Goal本文)をPRに含める。</span></span></label>
|
|
4929
|
-
<label class="rdrow"><input type="checkbox" id="rdPrWhatChanged"${state.rdDraft.prSections.whatChanged ? ' checked' : ''}>
|
|
4930
|
-
<span><span class="rdlabel">What changed</span>
|
|
4931
|
-
<span class="rdhint">変更ファイルの一覧をPRに含める。</span></span></label>
|
|
4932
|
-
<label class="rdrow"><input type="checkbox" id="rdPrHowToReview"${state.rdDraft.prSections.howToReview ? ' checked' : ''}>
|
|
4933
|
-
<span><span class="rdlabel">How to review</span>
|
|
4934
|
-
<span class="rdhint">上のレビュー指示(Review instructions)をPR本文にも載せる。</span></span></label>
|
|
4935
|
-
<label class="rdrow"><input type="checkbox" id="rdPrTests"${state.rdDraft.prSections.tests ? ' checked' : ''}>
|
|
4936
|
-
<span><span class="rdlabel">Tests</span>
|
|
4937
|
-
<span class="rdhint">Managerが実行したテスト結果をPRに含める。</span></span></label>
|
|
4938
|
-
<label class="rdrow"><input type="checkbox" id="rdPrScreenshots"${state.rdDraft.prSections.screenshots ? ' checked' : ''}>
|
|
4939
|
-
<span><span class="rdlabel">Screenshots</span>
|
|
4940
|
-
<span class="rdhint">証跡(スクショ/GIF)へのリンクをPRに含める。</span></span></label>
|
|
4941
|
-
<label class="rdrow"><input type="checkbox" id="rdPrRisk"${state.rdDraft.prSections.risk ? ' checked' : ''}>
|
|
4942
|
-
<span><span class="rdlabel">Risk</span>
|
|
4943
|
-
<span class="rdhint">注意が必要そうな変更範囲を一言添える。</span></span></label>
|
|
5030
|
+
<span class="rdhint">Goes into the pull request body, so whoever reviews it knows what to look for. The worker doesn't read this while it works — instructions for the AI belong in your project's CLAUDE.md / AGENTS.md.</span>
|
|
5031
|
+
<textarea class="rdtext" id="rdDescription" maxlength="500" rows="2" placeholder="例:「変更前後のスクショを見比べて、余白が揃っているか確認してください」など、レビューする人に伝えたいこと">${esc(state.rdDraft.description ?? '')}</textarea></span></label>
|
|
4944
5032
|
|
|
4945
5033
|
<div class="rdsub">レビューカードに出す項目 / Review card fields</div>
|
|
4946
5034
|
<label class="rdrow"><input type="checkbox" id="rdCardScreenshots"${state.rdDraft.reviewCard.screenshots ? ' checked' : ''}>
|
|
@@ -4956,15 +5044,8 @@ function renderRdEditor(){
|
|
|
4956
5044
|
<span><span class="rdlabel">Review language</span>
|
|
4957
5045
|
<span class="rdhint">レビュー要約(確認/変更)などManagerが生成する文言の言語。</span></span></label>`;
|
|
4958
5046
|
$('rdDefaultWantsPR').addEventListener('change', (e) => { state.rdDraft.defaultWantsPR = e.target.checked; });
|
|
4959
|
-
$('rdRequirePR').addEventListener('change', (e) => { state.rdDraft.requirePR = e.target.checked; });
|
|
4960
5047
|
$('rdRequireVerify').addEventListener('change', (e) => { state.rdDraft.requireVerifyPass = e.target.checked; });
|
|
4961
5048
|
$('rdDescription').addEventListener('input', (e) => { state.rdDraft.description = e.target.value; });
|
|
4962
|
-
$('rdPrSummary').addEventListener('change', (e) => { state.rdDraft.prSections.summary = e.target.checked; });
|
|
4963
|
-
$('rdPrWhatChanged').addEventListener('change', (e) => { state.rdDraft.prSections.whatChanged = e.target.checked; });
|
|
4964
|
-
$('rdPrHowToReview').addEventListener('change', (e) => { state.rdDraft.prSections.howToReview = e.target.checked; });
|
|
4965
|
-
$('rdPrTests').addEventListener('change', (e) => { state.rdDraft.prSections.tests = e.target.checked; });
|
|
4966
|
-
$('rdPrScreenshots').addEventListener('change', (e) => { state.rdDraft.prSections.screenshots = e.target.checked; });
|
|
4967
|
-
$('rdPrRisk').addEventListener('change', (e) => { state.rdDraft.prSections.risk = e.target.checked; });
|
|
4968
5049
|
$('rdCardScreenshots').addEventListener('change', (e) => { state.rdDraft.reviewCard.screenshots = e.target.checked; });
|
|
4969
5050
|
$('rdLanguage').addEventListener('change', (e) => { state.rdDraft.language = e.target.value; });
|
|
4970
5051
|
}
|
|
@@ -5456,6 +5537,171 @@ function renderReviewAttach(){
|
|
|
5456
5537
|
bar.innerHTML = reviewAttach.map((a, i) => `<span class="reqthumb"><img src="${esc(a.url)}" alt=""><button type="button" class="rm" data-ri="${i}" aria-label="Remove">×</button></span>`).join('');
|
|
5457
5538
|
for (const b of bar.querySelectorAll('.rm')) b.onclick = () => { reviewAttach.splice(Number(b.dataset.ri), 1); renderReviewAttach(); };
|
|
5458
5539
|
}
|
|
5540
|
+
// 返事は "auto" で送る (ONE-CONVERSATION §1.3/§1.4)。
|
|
5541
|
+
//
|
|
5542
|
+
// 行き先を決めるのは AI 本人で、この層は言われたとおりに配線するだけ。今までは中身に
|
|
5543
|
+
// 関わらず全部「直して」で、「なんでこうしたの?」と聞いただけでゴールが Doing に戻り
|
|
5544
|
+
// ワーカーが走っていた。人はモードを普段選ばない——AI が迷った時だけ選ぶ。
|
|
5545
|
+
const REPLY_CHOICE_LABEL = { continue: 'Fix this', rescope: 'Rethink it', split: 'Separate ticket', answer: 'Just a question' };
|
|
5546
|
+
// The one waiting line's two labels. Local to the reply note on purpose — STATUS_LABEL
|
|
5547
|
+
// is the task/workflow vocabulary and this is neither (CDO §5).
|
|
5548
|
+
const REPLY_WAIT_ROUTING = 'Thinking…';
|
|
5549
|
+
const REPLY_WAIT_ANSWERING = 'Answering…';
|
|
5550
|
+
async function postReply(goalId, text, outcome = 'auto'){
|
|
5551
|
+
const r = await fetch(withKey(`/api/goals/${goalId}/dismiss`), { method: 'POST',
|
|
5552
|
+
headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, outcome }) });
|
|
5553
|
+
if (!r.ok) throw new Error();
|
|
5554
|
+
return r.json();
|
|
5555
|
+
}
|
|
5556
|
+
// One line under whichever reply box was used. It is held in a variable and
|
|
5557
|
+
// PAINTED after every render, because a reply writes to the goal, which arrives
|
|
5558
|
+
// back over SSE, which rebuilds the list — a node appended straight to the DOM
|
|
5559
|
+
// disappears a second later (and an answer nobody sees is the same as no answer).
|
|
5560
|
+
let replyNoteState = null; // { anchorSel, you, phase, html, resolve? }
|
|
5561
|
+
function paintReplyNote(){
|
|
5562
|
+
for (const stale of document.querySelectorAll('.replynote')) stale.remove();
|
|
5563
|
+
paintSettledThreads();
|
|
5564
|
+
if (!replyNoteState) return;
|
|
5565
|
+
const anchor = document.querySelector(replyNoteState.anchorSel);
|
|
5566
|
+
if (!anchor) return;
|
|
5567
|
+
for (const stale of document.querySelectorAll('.replynote')) stale.remove();
|
|
5568
|
+
const el = document.createElement('div');
|
|
5569
|
+
el.className = 'qsub replynote';
|
|
5570
|
+
el.innerHTML = replyNoteState.html;
|
|
5571
|
+
// BELOW the reply box, never beside it (§1.4). The review card's box lives in a
|
|
5572
|
+
// flex row of buttons — appending inside it puts the choices next to the send
|
|
5573
|
+
// arrow, on top of it at narrow widths.
|
|
5574
|
+
const row = anchor.closest('.sa-act') ?? anchor.parentElement;
|
|
5575
|
+
if (row?.parentElement) row.after(el); else row?.appendChild(el);
|
|
5576
|
+
for (const b of el.querySelectorAll('[data-rc]')) b.onclick = () => {
|
|
5577
|
+
const pick = b.dataset.rc, done = replyNoteState?.resolve;
|
|
5578
|
+
replyNoteState = null; paintReplyNote(); done?.(pick);
|
|
5579
|
+
};
|
|
5580
|
+
}
|
|
5581
|
+
// Every open card with past review turns shows them, whether or not a reply is in
|
|
5582
|
+
// flight — this is what makes "close it, come back tomorrow" work (CDO §3.1).
|
|
5583
|
+
function paintSettledThreads(){
|
|
5584
|
+
for (const inp of document.querySelectorAll('#seeall .sa-chat[data-goal]')) {
|
|
5585
|
+
const goalId = Number(inp.dataset.goal);
|
|
5586
|
+
loadReplyThread(goalId);
|
|
5587
|
+
if (replyNoteState?.goalId === goalId) continue; // the live note draws its own history
|
|
5588
|
+
const html = replyPastHtmlFor(goalId);
|
|
5589
|
+
if (!html) continue;
|
|
5590
|
+
const row = inp.closest('.sa-act') ?? inp.parentElement;
|
|
5591
|
+
if (!row?.parentElement) continue;
|
|
5592
|
+
const el = document.createElement('div');
|
|
5593
|
+
el.className = 'qsub replynote';
|
|
5594
|
+
el.innerHTML = html;
|
|
5595
|
+
row.after(el);
|
|
5596
|
+
}
|
|
5597
|
+
}
|
|
5598
|
+
function setReplyNote(anchorSel, html, resolve){
|
|
5599
|
+
replyNoteState = anchorSel ? { ...replyNoteState, anchorSel, html, resolve } : null;
|
|
5600
|
+
paintReplyNote();
|
|
5601
|
+
}
|
|
5602
|
+
// Past turns of this card's reply thread, by goal id. The exchange is not a session
|
|
5603
|
+
// thing: every message this flow writes is on the goal (`goal.messages`, via:'review'),
|
|
5604
|
+
// so reopening the card next week must show it exactly as it settled (CDO §3.1).
|
|
5605
|
+
// Fetched once per goal when the card renders; the live state machine in
|
|
5606
|
+
// replyNoteState only ever covers a reply that is still in flight right now.
|
|
5607
|
+
const replyThread = {}; // goalId -> [{from, via, text}]
|
|
5608
|
+
const replyThreadLoading = new Set();
|
|
5609
|
+
function loadReplyThread(goalId){
|
|
5610
|
+
if (replyThread[goalId] || replyThreadLoading.has(goalId)) return;
|
|
5611
|
+
replyThreadLoading.add(goalId);
|
|
5612
|
+
fetch(withKey(`/api/goals/${goalId}/messages`))
|
|
5613
|
+
.then((r) => (r.ok ? r.json() : { messages: [] }))
|
|
5614
|
+
.then((b) => {
|
|
5615
|
+
replyThread[goalId] = (b.messages ?? []).filter((m) => m.via === 'review');
|
|
5616
|
+
if (replyThread[goalId].length) saRender();
|
|
5617
|
+
})
|
|
5618
|
+
.catch(() => { replyThread[goalId] = []; })
|
|
5619
|
+
.finally(() => replyThreadLoading.delete(goalId));
|
|
5620
|
+
}
|
|
5621
|
+
// Settled history: no shimmer, nothing pending — this already happened.
|
|
5622
|
+
function replyPastHtml(){
|
|
5623
|
+
const goalId = replyNoteState?.goalId;
|
|
5624
|
+
return replyPastHtmlFor(goalId);
|
|
5625
|
+
}
|
|
5626
|
+
function replyPastHtmlFor(goalId){
|
|
5627
|
+
const msgs = goalId == null ? [] : (replyThread[goalId] ?? []);
|
|
5628
|
+
return msgs.map((m) => (m.from === 'you' ? replySaidHtml(m.text)
|
|
5629
|
+
: m.from === 'ai' ? replyAnsweredHtml(m.text)
|
|
5630
|
+
: replyNoteRowHtml(esc(m.text)))).join('');
|
|
5631
|
+
}
|
|
5632
|
+
|
|
5633
|
+
// The reply box's thread rows. NOT the centre feed's .cc-you/.cc-say: every rule for
|
|
5634
|
+
// that vocabulary is scoped to #fsRoot, and the review card lives in #seeall — the
|
|
5635
|
+
// markup rendered there came out as unstyled black text (CDO decision §1). The card
|
|
5636
|
+
// already draws You/Claude rows for Activity with `.txr`, correctly scoped and
|
|
5637
|
+
// weighted, so this reuses those instead of defining anything new.
|
|
5638
|
+
function replySaidHtml(you){
|
|
5639
|
+
return `<div class="txr you"><span class="w">You</span><span class="m">${esc(you)}</span></div>`;
|
|
5640
|
+
}
|
|
5641
|
+
// One indicator that changes its label — never a second spinner (CDO decision §2, Q2).
|
|
5642
|
+
function replyWaitHtml(label){
|
|
5643
|
+
return `<div class="txr note"><span class="w"></span><span class="m"><span class="shimmer">${esc(label)}</span></span></div>`;
|
|
5644
|
+
}
|
|
5645
|
+
function replyNoteRowHtml(body){
|
|
5646
|
+
return `<div class="txr note"><span class="w"></span><span class="m">${body}</span></div>`;
|
|
5647
|
+
}
|
|
5648
|
+
function replyAnsweredHtml(text){
|
|
5649
|
+
return `<div class="txr cl"><span class="w">Claude</span><span class="m">${esc(text)}</span></div>`;
|
|
5650
|
+
}
|
|
5651
|
+
// Sent, and nothing decided yet. Shown immediately — the wait is real (a routing
|
|
5652
|
+
// call, then the answer), so the honest thing is to say it is thinking, not to
|
|
5653
|
+
// pretend the reply landed somewhere it has not.
|
|
5654
|
+
function showReplySent(anchorSel, you, goalId){
|
|
5655
|
+
replyNoteState = { anchorSel, you, goalId, phase: 'sent' };
|
|
5656
|
+
setReplyNote(anchorSel, replyPastHtml() + replySaidHtml(you) + replyWaitHtml(REPLY_WAIT_ROUTING));
|
|
5657
|
+
}
|
|
5658
|
+
// AI said it cannot tell (§1.4): the goal has NOT been touched, so this asks and
|
|
5659
|
+
// sends again with the chosen destination. Leaving it unanswered changes nothing.
|
|
5660
|
+
function askReplyDestination(anchorSel, choices){
|
|
5661
|
+
return new Promise((resolve) => {
|
|
5662
|
+
const said = replyNoteState?.you ? replySaidHtml(replyNoteState.you) : '';
|
|
5663
|
+
// The You row stays above the chips so "this" has something to point at (CDO Q5).
|
|
5664
|
+
setReplyNote(anchorSel, replyPastHtml() + said + replyNoteRowHtml('Which one is this?')
|
|
5665
|
+
+ `<div class="qchips">${(choices || []).map((c) =>
|
|
5666
|
+
`<button type="button" class="qchip" data-rc="${esc(c)}">${esc(REPLY_CHOICE_LABEL[c] ?? c)}</button>`).join('')}</div>`, resolve);
|
|
5667
|
+
});
|
|
5668
|
+
}
|
|
5669
|
+
// answer (§1.3): nothing was built, so there is no task to watch — the reply comes
|
|
5670
|
+
// back as a message on the goal. Show it where the question was asked, so an answer
|
|
5671
|
+
// cannot arrive somewhere nobody is looking.
|
|
5672
|
+
async function showAnswerWhenItArrives(anchorSel, goalId, seen){
|
|
5673
|
+
const mine = () => replyPastHtml() + (replyNoteState?.you ? replySaidHtml(replyNoteState.you) : '');
|
|
5674
|
+
setReplyNote(anchorSel, mine() + replyWaitHtml(REPLY_WAIT_ANSWERING));
|
|
5675
|
+
for (let i = 0; i < 120; i++) {
|
|
5676
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
5677
|
+
let msgs = [];
|
|
5678
|
+
try { msgs = (await (await fetch(withKey(`/api/goals/${goalId}/messages`))).json()).messages ?? []; } catch { continue; }
|
|
5679
|
+
const said = msgs.slice(seen).find((m) => m.from === 'ai' || m.from === 'manager');
|
|
5680
|
+
if (said) {
|
|
5681
|
+
// It is history now — render it exactly as a reopened card will (CDO §3.1), and
|
|
5682
|
+
// keep it anchored where it was asked, so this works in the list's reply box too
|
|
5683
|
+
// (that one is not inside #seeall and would otherwise lose the answer).
|
|
5684
|
+
replyThread[goalId] = msgs.filter((m) => m.via === 'review');
|
|
5685
|
+
return setReplyNote(anchorSel, replyPastHtmlFor(goalId));
|
|
5686
|
+
}
|
|
5687
|
+
}
|
|
5688
|
+
setReplyNote(anchorSel, mine() + replyNoteRowHtml('No answer came back.'));
|
|
5689
|
+
}
|
|
5690
|
+
// The whole reply flow in one place: send as auto, ask if the AI was unsure, and
|
|
5691
|
+
// keep an answer visible. Returns the settled body, or null if the person cancelled.
|
|
5692
|
+
async function replyToGoal(goalId, text, anchorSel){
|
|
5693
|
+
const seen = state.goals.find((g) => g.id === Number(goalId))?.messageCount ?? 0;
|
|
5694
|
+
showReplySent(anchorSel, text, Number(goalId)); // before the round trip: the words stay, and it says it is thinking
|
|
5695
|
+
let body = await postReply(goalId, text, 'auto');
|
|
5696
|
+
if (body.outcome === 'unsure') {
|
|
5697
|
+
const picked = await askReplyDestination(anchorSel, body.choices);
|
|
5698
|
+
if (!picked) return null;
|
|
5699
|
+
body = await postReply(goalId, text, picked);
|
|
5700
|
+
}
|
|
5701
|
+
// seen + 1: the question itself was just recorded; the answer is the one after it.
|
|
5702
|
+
if (body.outcome === 'answer') showAnswerWhenItArrives(anchorSel, goalId, seen + 1);
|
|
5703
|
+
return body;
|
|
5704
|
+
}
|
|
5459
5705
|
async function sendReviewDismiss(text){
|
|
5460
5706
|
const goal = state.goals.find((g) => g.id === state.reviewOpenGoal);
|
|
5461
5707
|
if (!goal) return;
|
|
@@ -5466,15 +5712,15 @@ async function sendReviewDismiss(text){
|
|
|
5466
5712
|
? `${text}${text ? '\n\n' : ''}添付画像(workerはこのパスをReadで確認できます): ${imgs.map((a) => a.path).join(', ')}`
|
|
5467
5713
|
: text;
|
|
5468
5714
|
try {
|
|
5469
|
-
const
|
|
5470
|
-
|
|
5471
|
-
if (!r.ok) throw new Error();
|
|
5472
|
-
const body = await r.json();
|
|
5715
|
+
const body = await replyToGoal(goal.id, fullText, '#reviewFixInput');
|
|
5716
|
+
if (!body) return; // unsure, and nobody chose
|
|
5473
5717
|
upsert(state.goals, body.goal);
|
|
5474
5718
|
if (body.task) upsert(state.tasks, body.task);
|
|
5475
5719
|
$('reviewFixInput').value = '';
|
|
5476
5720
|
reviewAttach = []; renderReviewAttach();
|
|
5477
|
-
|
|
5721
|
+
// A question changed nothing, so closing the card would hide the answer that is
|
|
5722
|
+
// on its way. Only a reply that actually moved the goal closes it.
|
|
5723
|
+
if (body.outcome !== 'answer') closeReviewChecklist();
|
|
5478
5724
|
render();
|
|
5479
5725
|
} catch { showErr('Dismiss failed.'); }
|
|
5480
5726
|
}
|
|
@@ -5696,14 +5942,12 @@ async function sendReject(goalId){
|
|
|
5696
5942
|
async function sendDismiss(goalId, text){
|
|
5697
5943
|
$('gdApprove').disabled = true; $('gdDismiss').disabled = true; $('gdSend').disabled = true;
|
|
5698
5944
|
try {
|
|
5699
|
-
const
|
|
5700
|
-
|
|
5701
|
-
if (!r.ok) throw new Error();
|
|
5702
|
-
const body = await r.json();
|
|
5945
|
+
const body = await replyToGoal(goalId, text, '#gdRevisionInput');
|
|
5946
|
+
if (!body) { $('gdApprove').disabled = false; $('gdDismiss').disabled = false; $('gdSend').disabled = false; return; }
|
|
5703
5947
|
upsert(state.goals, body.goal);
|
|
5704
5948
|
upsert(state.tasks, body.task);
|
|
5705
5949
|
$('gdRevisionInput').value = '';
|
|
5706
|
-
closeGoalDetail();
|
|
5950
|
+
if (body.outcome !== 'answer') closeGoalDetail(); // a question leaves the card open for its answer
|
|
5707
5951
|
render();
|
|
5708
5952
|
} catch { showErr('Dismiss failed.'); }
|
|
5709
5953
|
$('gdApprove').disabled = false; $('gdDismiss').disabled = false; $('gdSend').disabled = false;
|
|
@@ -5759,11 +6003,13 @@ $('gdRevisionInput').addEventListener('keydown', (e) => {
|
|
|
5759
6003
|
|
|
5760
6004
|
function render(){
|
|
5761
6005
|
renderProjects(); renderStream(); renderQueue(); renderTasks(); renderActPanel(); wireWfEditButtons();
|
|
6006
|
+
renderCtxBar(); // the folder above the composer follows the active project
|
|
5762
6007
|
renderFlagship();
|
|
5763
6008
|
if (state.reviewOpenGoal != null) renderReviewChecklist();
|
|
5764
6009
|
if (state.goalDetailOpen != null) renderGoalDetail();
|
|
5765
6010
|
if (typeof SA !== 'undefined' && SA.open) saSync(); // keep the open See all Ledger live
|
|
5766
6011
|
const bv = $('botver'); if (bv && state.uiVersion) bv.textContent = 'build ' + String(state.uiVersion).slice(0, 8);
|
|
6012
|
+
paintReplyNote(); // a reply's answer/question survives the re-render it caused
|
|
5767
6013
|
}
|
|
5768
6014
|
|
|
5769
6015
|
// ============================================================================
|
|
@@ -6093,8 +6339,9 @@ function fsBoardBucket(goal, tasks){
|
|
|
6093
6339
|
if (s === 'needsInput') return 'needs'; // a clarifying question is waiting on the user
|
|
6094
6340
|
if (s === 'blocked' && goal?.blocked?.kind === 'paused') return 'doing'; // paused by user — stays in Doing, shown amber "Paused" (Masa 2026-07-19: 1カードのまま黄色に・Needs youへ移動しない)
|
|
6095
6341
|
if (['blocked', 'failed', 'partial', 'interrupted'].includes(s)) return 'needs'; // stopped/errored — needs YOUR action (retry), like the flagship board which puts .drow.err in Needs you. Not "Doing" — Doing is only what's actively moving (Masa 2026-07-18)
|
|
6096
|
-
if (s === 'review') return 'review';
|
|
6342
|
+
if (s === 'review' || s === 'folded') return 'review'; // folded (PR filed for review) reads as Review, not Doing — goalGroupStatus parity (Masa 2026-07-21)
|
|
6097
6343
|
if (s === 'done' || s === 'reverted' || s === 'skipped') return 'done'; // terminal; skipped is finished, not in flight (it used to fall through to the doing default)
|
|
6344
|
+
if (s === 'rejected') return 'rejected'; // Reject = closed. Parked in the list's own Dismissed fold; the board drops it entirely (a closed todo must not linger in Doing — Masa 2026-07-21). Both 'folded' and 'rejected' used to fall through to the doing default.
|
|
6098
6345
|
if (s === 'stacked' || s === 'sending') return 'todo';
|
|
6099
6346
|
if (s === 'running' && !goal?.startedAt) return 'todo'; // goalChip: planned, nothing started yet
|
|
6100
6347
|
// Otherwise the goal is in flight (planning / retesting / running-and-started). BUT a
|
|
@@ -6217,8 +6464,8 @@ function renderFsBoard(){
|
|
|
6217
6464
|
return `<div class="lbc${quiet ? ' is-quiet' : ''}">${h}</div>`;
|
|
6218
6465
|
};
|
|
6219
6466
|
const no = (g) => goalNo.get(g.id) ?? Infinity;
|
|
6220
|
-
const bucket = { todo: [], doing: [], needs: [], review: [], done: [], later: [] };
|
|
6221
|
-
for (const g of pgoals) bucket[fsBoardBucket(g, list)].push(g);
|
|
6467
|
+
const bucket = { todo: [], doing: [], needs: [], review: [], done: [], later: [], rejected: [] };
|
|
6468
|
+
for (const g of pgoals) bucket[fsBoardBucket(g, list)].push(g); // 'rejected' is collected but never rendered as a column below — closed goals disappear from the board
|
|
6222
6469
|
for (const k of Object.keys(bucket)) bucket[k].sort((a, b) => k === 'done' ? no(b) - no(a) : no(a) - no(b));
|
|
6223
6470
|
// To Do / Doing / Needs you / Review / Done / Later — the original design's columns
|
|
6224
6471
|
// (Masa 2026-07-16). "Needs you" = a clarifying question is waiting on the user —
|
|
@@ -6403,6 +6650,10 @@ function fsBucketTodo(){
|
|
|
6403
6650
|
const list = state.tasks.filter((t) => t.projectId === state.active);
|
|
6404
6651
|
const order = state.queueOrder[state.active] ?? [];
|
|
6405
6652
|
const byQueue = (a, b) => { const ia = order.indexOf(a.id), ib = order.indexOf(b.id); return (ia < 0 ? 1e9 : ia) - (ib < 0 ? 1e9 : ib) || a.num - b.num; };
|
|
6653
|
+
// A closed (rejected) goal's tasks stay 'done' after Reject, so they used to leak into the
|
|
6654
|
+
// "✓ Done" fold below AND show in the Dismissed fold — the same closed todo listed twice.
|
|
6655
|
+
// Its home is the Dismissed fold; keep its done tasks out of Done (Masa 2026-07-21).
|
|
6656
|
+
const closedGoalIds = new Set(pgoals.filter((g) => g.status === 'rejected').map((g) => g.id));
|
|
6406
6657
|
return {
|
|
6407
6658
|
pgoals, list,
|
|
6408
6659
|
running: list.filter((t) => t.status === 'running'),
|
|
@@ -6411,7 +6662,7 @@ function fsBucketTodo(){
|
|
|
6411
6662
|
needsInput: pgoals.filter((g) => g.status === 'needsInput'), // a clarifying question is waiting on the user (board's "Needs you")
|
|
6412
6663
|
reviewGoals: pgoals.filter((g) => g.status === 'review'),
|
|
6413
6664
|
queued: list.filter((t) => t.status === 'queued' && !t.reply).sort(byQueue),
|
|
6414
|
-
doneAll: list.filter((t) => ['done', 'skipped'].includes(t.status)).sort((a, b) => b.num - a.num),
|
|
6665
|
+
doneAll: list.filter((t) => ['done', 'skipped'].includes(t.status) && !closedGoalIds.has(t.goalId)).sort((a, b) => b.num - a.num),
|
|
6415
6666
|
later: pgoals.filter((g) => g.status === 'pending'),
|
|
6416
6667
|
rejected: pgoals.filter((g) => g.status === 'rejected'), // 07-20c: Reject parks goals here (own fold, like Done)
|
|
6417
6668
|
};
|
|
@@ -7730,6 +7981,12 @@ async function boot(){
|
|
|
7730
7981
|
if (window.__GALDA_DISCONNECTED) return bootDisconnected(window.__GALDA_DISCONNECTED);
|
|
7731
7982
|
$('input').value = localStorage.getItem('draft') ?? '';
|
|
7732
7983
|
await refresh();
|
|
7984
|
+
// The folder sits above the composer and is read on every screen, so its list is
|
|
7985
|
+
// fetched once at boot rather than on first open: without HOME the bar can only
|
|
7986
|
+
// print the absolute path (measured — it read "/var/folders/…/agent-manager"
|
|
7987
|
+
// instead of "~/agent-manager" until the menu had been opened once). One read of
|
|
7988
|
+
// one directory, not the 500KB-per-task class the on-open rule exists for.
|
|
7989
|
+
loadRepos();
|
|
7733
7990
|
flushOutbox();
|
|
7734
7991
|
connectSSE();
|
|
7735
7992
|
startHealthPoll();
|
|
@@ -7770,7 +8027,7 @@ async function postOutboxItem(item){
|
|
|
7770
8027
|
posting.add(item.oid);
|
|
7771
8028
|
try {
|
|
7772
8029
|
const r = await fetch(withKey('/api/tasks'), { method: 'POST', headers: { 'content-type': 'application/json' },
|
|
7773
|
-
body: JSON.stringify({ projectId: item.projectId, text: item.text, pr: item.pr, model: item.model, effort: item.effort, agent: item.agent, mode: item.mode, skill: item.skill, images: item.images, pending: item.pending, review: item.review }) });
|
|
8030
|
+
body: JSON.stringify({ projectId: item.projectId, text: item.text, pr: item.pr, model: item.model, effort: item.effort, agent: item.agent, mode: item.mode, skill: item.skill, worktree: item.worktree, images: item.images, pending: item.pending, review: item.review }) });
|
|
7774
8031
|
if (r.status === 402) {
|
|
7775
8032
|
const data = await r.json().catch(() => ({}));
|
|
7776
8033
|
outbox.mark(item.oid, 'unsent'); // same status as any other failed send — the existing 15s auto-flush retries it (and starts succeeding the moment a license activates), the existing queue resend affordance also works
|
|
@@ -7942,6 +8199,9 @@ async function send(){
|
|
|
7942
8199
|
oid: `o${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
|
7943
8200
|
projectId: state.active, text, pr: 'auto', model, effort, agent: state.connectedApp,
|
|
7944
8201
|
mode, skill: skill || null,
|
|
8202
|
+
// Only the OFF case travels: the engine stores an explicit false and leaves every
|
|
8203
|
+
// other goal meaning exactly what it meant (engine #268).
|
|
8204
|
+
worktree: worktreeOn() ? undefined : false,
|
|
7945
8205
|
pending: later, review,
|
|
7946
8206
|
images: attach.map((a) => a.path), imageUrls: attach.map((a) => a.url),
|
|
7947
8207
|
status: 'sending', ts: Date.now(),
|
|
@@ -8103,7 +8363,7 @@ function closeSummaryPera(){ $('summaryOverlay').classList.remove('show'); }
|
|
|
8103
8363
|
// 4 archived / 5 reverted — session-local; the server round-trip is what
|
|
8104
8364
|
// settles it (see saSync's "returned to review" reset).
|
|
8105
8365
|
// ============================================================================
|
|
8106
|
-
const SA = { open: false, items: [], diffs: {}, actLog: {}, shotBox: {}, detOpen: new Set(), focusGid: null, focusScrolled: false, focusTimer: null, cur: null };
|
|
8366
|
+
const SA = { open: false, items: [], diffs: {}, actLog: {}, msgs: {}, convOpen: new Set(), shotBox: {}, detOpen: new Set(), focusGid: null, focusScrolled: false, focusTimer: null, cur: null };
|
|
8107
8367
|
window.SA = SA; // top-level const isn't a window property; external checks probe window.SA (same convention as window.state above)
|
|
8108
8368
|
const SA_SVG = {
|
|
8109
8369
|
ok: '<svg viewBox="0 0 24 24"><path d="M4 12l5 5L20 6"/></svg>',
|
|
@@ -8169,6 +8429,11 @@ function saRows(){
|
|
|
8169
8429
|
const ids = [...r.goalIds, ...folded.map((f) => f.id)];
|
|
8170
8430
|
return ts.filter((t) => ids.includes(t.goalId)).flatMap((t) => t.shots ?? []);
|
|
8171
8431
|
})(),
|
|
8432
|
+
// How many messages this goal's conversation holds (§1.1). A COUNT, not the
|
|
8433
|
+
// conversation: state carries every card on every tick, and 100 cards dragging
|
|
8434
|
+
// 100 threads is what the engine deliberately avoided. 0/undefined means the
|
|
8435
|
+
// section is not rendered at all (§1.5-3: no container for something absent).
|
|
8436
|
+
messageCount: Number(g?.messageCount) || 0,
|
|
8172
8437
|
// The task whose implementation log the engine froze at completion (P1.5). The
|
|
8173
8438
|
// lines are NOT in state — 1000 of them per task would bloat every page load — so
|
|
8174
8439
|
// this is just the id to ask for when the reviewer opens Activity.
|
|
@@ -8243,10 +8508,18 @@ function saRows(){
|
|
|
8243
8508
|
// has called it since the review-card redesign; Request and Result carry that ground now.)
|
|
8244
8509
|
// Unified-diff → GitHub two-gutter model (REVIEW-CARD-SPEC §7 "GitHub diff = Bright"):
|
|
8245
8510
|
// walk the @@ hunk headers to recover the old/new line numbers, emit one row per line
|
|
8246
|
-
// {o:oldNo|null, n:newNo|null, t:'add'|'del'|'ctx', c:text}.
|
|
8247
|
-
//
|
|
8248
|
-
//
|
|
8249
|
-
//
|
|
8511
|
+
// {o:oldNo|null, n:newNo|null, t:'add'|'del'|'ctx', c:text}. Pure, so it is extracted
|
|
8512
|
+
// and unit-tested in engine/test/review-card-diff.test.mjs.
|
|
8513
|
+
//
|
|
8514
|
+
// Word-level highlight (§7 "変わった単語だけ濃くハイライト", Masa 2026-07-22): the span
|
|
8515
|
+
// is MEASURED between the two lines the reviewer is already looking at — a common
|
|
8516
|
+
// prefix and suffix, with what is left over marked — never guessed. The earlier note
|
|
8517
|
+
// here said the highlight would be "fabricated evidence"; a character comparison of two
|
|
8518
|
+
// shown lines invents nothing. What WOULD be a guess is pairing the wrong removed line
|
|
8519
|
+
// with the wrong added one, so only an unambiguous pair is marked: a run of removals
|
|
8520
|
+
// immediately followed by a run of additions of the SAME length, paired in order. A
|
|
8521
|
+
// 3-for-2 replacement, an add with no matching del, a rewritten line where almost
|
|
8522
|
+
// nothing survives — all keep today's behaviour exactly (tint only, no span).
|
|
8250
8523
|
// How many body rows the diff really has, ignoring the display cap. A big change used
|
|
8251
8524
|
// to be cut at 80 rows with nothing said (SPEC §7 asks for "…+N 行 · GitHub で開く ↗"
|
|
8252
8525
|
// precisely so a reviewer is never shown a slice that looks like the whole thing).
|
|
@@ -8277,8 +8550,61 @@ function saUnifiedToGh(diff, max){
|
|
|
8277
8550
|
}
|
|
8278
8551
|
return out;
|
|
8279
8552
|
}
|
|
8553
|
+
// The changed span between one removed line and the line that replaced it: skip the
|
|
8554
|
+
// common head, skip the common tail, and what is left in the middle is what changed.
|
|
8555
|
+
// Returns the two offset pairs plus `kept` — how much of the longer line survived —
|
|
8556
|
+
// which is the evidence that these two lines ARE the same line. Null only when the
|
|
8557
|
+
// lines are identical (nothing to point at).
|
|
8558
|
+
function saWordSpan(delText, addText){
|
|
8559
|
+
const a = String(delText ?? ''), b = String(addText ?? '');
|
|
8560
|
+
if (a === b) return null;
|
|
8561
|
+
const max = Math.min(a.length, b.length);
|
|
8562
|
+
let p = 0;
|
|
8563
|
+
while (p < max && a[p] === b[p]) p++;
|
|
8564
|
+
let sfx = 0;
|
|
8565
|
+
while (sfx < max - p && a[a.length - 1 - sfx] === b[b.length - 1 - sfx]) sfx++;
|
|
8566
|
+
return { del: [p, a.length - sfx], add: [p, b.length - sfx], kept: (p + sfx) / Math.max(a.length, b.length, 1) };
|
|
8567
|
+
}
|
|
8568
|
+
// How much of a line must survive before two lines count as the same line. Below this
|
|
8569
|
+
// the "change" is a rewrite: marking almost the whole line says less than the Bright
|
|
8570
|
+
// tint already does, and a low score is also how an unrelated pair gets rejected.
|
|
8571
|
+
const SA_WORD_KEEP = 0.34;
|
|
8572
|
+
// Mark the changed span on removed/added pairs, in place.
|
|
8573
|
+
//
|
|
8574
|
+
// Pairing is the only place a guess could enter, so it is decided by measurement: each
|
|
8575
|
+
// removed line takes the added line it shares the most text with, and the pair is only
|
|
8576
|
+
// marked when that shared text clears SA_WORD_KEEP. Everything else — a pure addition,
|
|
8577
|
+
// a pure removal, a rewrite, an unrelated neighbour — is left exactly as it renders
|
|
8578
|
+
// today (tint only, no span).
|
|
8579
|
+
//
|
|
8580
|
+
// Measured on this product's own history (2026-07-22): the first rule written here
|
|
8581
|
+
// paired blocks in order and required equal lengths, and lit up NOTHING, because the
|
|
8582
|
+
// common shape is "one line replaced, plus a comment added above it" — in-order pairing
|
|
8583
|
+
// matched the replaced line against the comment.
|
|
8584
|
+
function saMarkWordSpans(rows){
|
|
8585
|
+
for (let i = 0; i < rows.length; i++) {
|
|
8586
|
+
if (rows[i].t !== 'del') continue;
|
|
8587
|
+
let d = i; while (d < rows.length && rows[d].t === 'del') d++;
|
|
8588
|
+
let a = d; while (a < rows.length && rows[a].t === 'add') a++;
|
|
8589
|
+
const used = new Set();
|
|
8590
|
+
for (let k = i; k < d; k++) {
|
|
8591
|
+
let best = null, bestIdx = -1;
|
|
8592
|
+
for (let j = d; j < a; j++) {
|
|
8593
|
+
if (used.has(j)) continue;
|
|
8594
|
+
const span = saWordSpan(rows[k].c, rows[j].c);
|
|
8595
|
+
if (span && (!best || span.kept > best.kept)) { best = span; bestIdx = j; }
|
|
8596
|
+
}
|
|
8597
|
+
if (!best || best.kept < SA_WORD_KEEP) continue;
|
|
8598
|
+
used.add(bestIdx);
|
|
8599
|
+
rows[k].hi = best.del;
|
|
8600
|
+
rows[bestIdx].hi = best.add;
|
|
8601
|
+
}
|
|
8602
|
+
i = a - 1;
|
|
8603
|
+
}
|
|
8604
|
+
return rows;
|
|
8605
|
+
}
|
|
8280
8606
|
function saGhDiffHtml(diff, pr){
|
|
8281
|
-
const rows = saUnifiedToGh(diff);
|
|
8607
|
+
const rows = saMarkWordSpans(saUnifiedToGh(diff));
|
|
8282
8608
|
if (!rows.length) return '';
|
|
8283
8609
|
// Say what was left out. Before this, a 5000-line change rendered its first 80 rows
|
|
8284
8610
|
// and stopped — a quiet lie, and the one thing SPEC §7 names as forbidden.
|
|
@@ -8305,6 +8631,52 @@ function saLogRowHtml(line){
|
|
|
8305
8631
|
? `<div class="txr tool"><span class="w">tool</span><span class="m"><b>${esc(m[1])}</b> ${esc(m[2])}</span></div>`
|
|
8306
8632
|
: `<div class="txr cl"><span class="w"></span><span class="m">${esc(s)}</span></div>`;
|
|
8307
8633
|
}
|
|
8634
|
+
// One line of the goal's conversation (ONE-CONVERSATION §1.1). Several entrances —
|
|
8635
|
+
// the composer, a review reply, a thread reply — are one column here, because to the
|
|
8636
|
+
// person who wrote them they were one conversation. `via` says which door a line came
|
|
8637
|
+
// through and nothing else: no per-entrance box, no grouping, no summarising. The text
|
|
8638
|
+
// is whatever was actually said (§ "会話を要約・分類・整形しない").
|
|
8639
|
+
const MSG_WHO = { you: 'You', ai: 'AI', manager: 'Manager' };
|
|
8640
|
+
// `thread` is our word for a reply that came in on the goal's own thread; a person reads
|
|
8641
|
+
// it as 'note'. The label is only shown when the entrance CHANGED from the line above —
|
|
8642
|
+
// five rows do not need to say 'chat' three times.
|
|
8643
|
+
const MSG_VIA = { composer: 'chat', review: 'review', thread: 'note' };
|
|
8644
|
+
function saMsgRowHtml(m, prevVia){
|
|
8645
|
+
const who = MSG_WHO[m?.from] ?? 'AI';
|
|
8646
|
+
const cls = m?.from === 'you' ? 'you' : m?.from === 'manager' ? 'mgr' : 'cl';
|
|
8647
|
+
// Manager rows carry no entrance: `who` already says this line is automatic.
|
|
8648
|
+
const via = m?.from === 'manager' ? '' : (MSG_VIA[m?.via] ?? '');
|
|
8649
|
+
const show = via && via !== prevVia;
|
|
8650
|
+
return `<div class="txr ${cls}"><span class="w">${who}</span><span class="m">${esc(String(m?.text ?? ''))}</span>`
|
|
8651
|
+
+ (show ? `<span class="cvia">${via}</span>` : '') + '</div>';
|
|
8652
|
+
}
|
|
8653
|
+
// The rows of a conversation, with each entrance shown only where it changes. A Manager
|
|
8654
|
+
// line never updates `prevVia` — a note in the middle must not make the chat that
|
|
8655
|
+
// continues after it look like a new entrance.
|
|
8656
|
+
//
|
|
8657
|
+
// Long ones fold in the MIDDLE (§B3): the first ask and the latest exchange are what get
|
|
8658
|
+
// read, the middle is what you go back for. From the 9th message: first 2 · +N more ·
|
|
8659
|
+
// last 4. Nothing is summarised and nothing is dropped — pressing it shows everything.
|
|
8660
|
+
// The row right after a fold always prints its entrance, because the change may be
|
|
8661
|
+
// inside what was folded away.
|
|
8662
|
+
const CONV_FOLD_FROM = 9, CONV_HEAD = 2, CONV_TAIL = 4;
|
|
8663
|
+
function saMsgRowsHtml(msgs, expanded){
|
|
8664
|
+
const list = msgs ?? [];
|
|
8665
|
+
const fold = !expanded && list.length >= CONV_FOLD_FROM;
|
|
8666
|
+
const shown = fold ? [...list.slice(0, CONV_HEAD), null, ...list.slice(-CONV_TAIL)] : list;
|
|
8667
|
+
const hidden = list.length - CONV_HEAD - CONV_TAIL;
|
|
8668
|
+
let prevVia = null;
|
|
8669
|
+
return shown.map((m) => {
|
|
8670
|
+
if (m === null) {
|
|
8671
|
+
prevVia = null;
|
|
8672
|
+
return `<div class="txr res convmore"><span class="w"></span><span class="m">+${hidden} more</span></div>`;
|
|
8673
|
+
}
|
|
8674
|
+
const html = saMsgRowHtml(m, prevVia);
|
|
8675
|
+
if (m?.from !== 'manager') prevVia = MSG_VIA[m?.via] ?? prevVia;
|
|
8676
|
+
return html;
|
|
8677
|
+
}).join('');
|
|
8678
|
+
}
|
|
8679
|
+
|
|
8308
8680
|
// Structured GitHub diff (REVIEW-CARD-SPEC §7): renders {o,n,t,c,h} rows with a
|
|
8309
8681
|
// word-level highlight (h) and, for a huge change, a "…+N 行 · GitHub で開く ↗"
|
|
8310
8682
|
// footer (only linking to GitHub when a PR exists). Mirrors the flagship artifact's
|
|
@@ -8315,7 +8687,12 @@ function saGhDiffRowsHtml(rows, more, pr){
|
|
|
8315
8687
|
const cls = l.t === 'add' ? 'gh-add' : l.t === 'del' ? 'gh-del' : 'gh-ctx';
|
|
8316
8688
|
const mk = l.t === 'add' ? '+' : l.t === 'del' ? '−' : '';
|
|
8317
8689
|
let code = esc(l.c);
|
|
8318
|
-
if (
|
|
8690
|
+
if (Array.isArray(l.hi)) {
|
|
8691
|
+
// Offsets, not a substring search: a word that appears twice on the line must be
|
|
8692
|
+
// marked where it actually changed, not at its first occurrence.
|
|
8693
|
+
const [a, b] = l.hi;
|
|
8694
|
+
code = `${esc(l.c.slice(0, a))}<span class="gh-hi">${esc(l.c.slice(a, b))}</span>${esc(l.c.slice(b))}`;
|
|
8695
|
+
} else if (l.h) { const h = esc(l.h); code = code.replace(h, `<span class="gh-hi">${h}</span>`); }
|
|
8319
8696
|
return `<div class="ghl ${cls}"><span class="gho">${l.o ?? ''}</span><span class="ghn">${l.n ?? ''}</span><span class="ghm">${mk}</span><span class="ghc">${code}</span></div>`;
|
|
8320
8697
|
}).join('') + '</div>';
|
|
8321
8698
|
const foot = more ? `<div class="ghfoot"><span>…+${more} 行</span>${pr ? ` · <a class="ghopen" href="${esc(pr)}" target="_blank" rel="noopener">GitHub で開く ↗</a>` : ''}</div>` : '';
|
|
@@ -8442,7 +8819,9 @@ function saItemHtml(it, i){
|
|
|
8442
8819
|
// address the worker declared IS the control (press it → the process starts on demand →
|
|
8443
8820
|
// it opens); its own note sits under it, and that note is where the status and any
|
|
8444
8821
|
// failure reason land after the press. Nothing declared → no section (§1.5-3). ──
|
|
8445
|
-
|
|
8822
|
+
// it.run?.compare/title alone (no cmd/url) has nothing a press can start — a
|
|
8823
|
+
// compare-only or title-only declaration must not draw the "Start it" button.
|
|
8824
|
+
const openBody = (it.run?.cmd || it.run?.url)
|
|
8446
8825
|
? `<div class="ropen" id="runlocal${i}"><button type="button" class="olink" data-saact="preview:${i}" id="prevlink${i}">${esc(it.run.url || 'Start it')}<span class="ar">↗</span></button><p class="rbody v">${esc(it.run.note || it.run.cmd || 'Starts when you press it.')}</p></div>`
|
|
8447
8826
|
: '';
|
|
8448
8827
|
// ── Also carries (Masa 2026-07-21): goals folded onto this review are logged HERE, inside
|
|
@@ -8556,6 +8935,26 @@ function saItemHtml(it, i){
|
|
|
8556
8935
|
: it.logTaskId != null
|
|
8557
8936
|
? `<section class="rsec ract"><div class="rsec-h">Activity</div><details class="lacc" data-detkey="${gk}:act" data-actlog="${it.logTaskId}"${actOpen}><summary><i class="ltog">›</i><div class="rtx lfirst">${saTxRowHtml(['user', it.goalText || it.title])}</div></summary><div class="lbody"><div class="rtx" data-actbody="${it.logTaskId}">${logHtml}</div>${discInner}</div></details></section>`
|
|
8558
8937
|
: '';
|
|
8938
|
+
// ── Conversation (ONE-CONVERSATION §1.1) ─────────────────────────────────────
|
|
8939
|
+
// Everything said about this ticket, in the order it was said, whichever door it
|
|
8940
|
+
// came through. Rendered ABOVE Activity: Activity is what the AI did, and what was
|
|
8941
|
+
// SAID comes before what was done. Fetched on open only — state carries a count,
|
|
8942
|
+
// never the thread (100 cards would drag 100 threads on every tick). A goal nobody
|
|
8943
|
+
// has talked to has no section at all (§1.5-3 存在しないものの器を先に見せない).
|
|
8944
|
+
const msgs = SA.msgs[it.goalId];
|
|
8945
|
+
const msgOpen = SA.detOpen.has(`${gk}:msg`) ? ' open' : '';
|
|
8946
|
+
const msgLast = Array.isArray(msgs) && msgs.length ? msgs[msgs.length - 1] : null;
|
|
8947
|
+
const msgSummary = msgLast
|
|
8948
|
+
? saMsgRowHtml(msgLast, null)
|
|
8949
|
+
: `<div class="txr"><span class="w"></span><span class="m">${it.messageCount} message${it.messageCount === 1 ? '' : 's'}</span></div>`;
|
|
8950
|
+
const msgBody = Array.isArray(msgs)
|
|
8951
|
+
? (msgs.length
|
|
8952
|
+
? saMsgRowsHtml(msgs, SA.convOpen.has(it.goalId))
|
|
8953
|
+
: '<div class="txr res"><span class="w"></span><span class="m">Nothing was said on this one.</span></div>')
|
|
8954
|
+
: '<div class="txr res"><span class="w"></span><span class="m">Loading…</span></div>';
|
|
8955
|
+
const conversationSec = it.messageCount > 0
|
|
8956
|
+
? `<section class="rsec rconv"><div class="rsec-h">Conversation</div><details class="lacc" data-detkey="${gk}:msg" data-msgs="${it.goalId}"${msgOpen}><summary><i class="ltog">›</i><div class="rtx lfirst">${msgSummary}</div></summary><div class="lbody"><div class="rtx" data-msgbody="${it.goalId}">${msgBody}</div></div></details></section>`
|
|
8957
|
+
: '';
|
|
8559
8958
|
const S = (label, body) => body ? `<section class="rsec"><div class="rsec-h">${label}</div>${body}</section>` : '';
|
|
8560
8959
|
return `<div class="sa-item lg redesign${it.goalId === SA.cur ? ' cur' : ''}" data-gid="${it.goalId}">
|
|
8561
8960
|
<div class="rhead"><span class="rtag">${it.no != null ? `#${it.no}` : ''}</span><span class="rst"><span class="rdot"></span>In review</span></div>
|
|
@@ -8571,9 +8970,10 @@ function saItemHtml(it, i){
|
|
|
8571
8970
|
${S('Proof', proofInner)}
|
|
8572
8971
|
${S('What changed', changedBody)}
|
|
8573
8972
|
${riskHtml}
|
|
8973
|
+
${conversationSec}
|
|
8574
8974
|
${activitySec}
|
|
8575
8975
|
${S('Workflow', saWorkflowHtml(it))}
|
|
8576
|
-
<div class="sa-act"><button class="sa-app" data-saact="app:${i}">${SA_SVG.ok}Approve</button><button class="sa-dis" data-saact="dis:${i}">Dismiss</button><div class="sa-chatwrap"><input class="sa-chat" data-i="${i}" placeholder="Reply, ask, or send back to the AI…"><button class="sa-send" data-saact="send:${i}" title="Send"><svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button></div><button class="sa-ic" title="Archive" data-saact="arch:${i}">${SA_SVG.arch}</button><button class="sa-ic" title="Revert" data-saact="rev:${i}">${SA_SVG.trash}</button></div>
|
|
8976
|
+
<div class="sa-act"><button class="sa-app" data-saact="app:${i}">${SA_SVG.ok}Approve</button><button class="sa-dis" data-saact="dis:${i}">Dismiss</button><div class="sa-chatwrap"><input class="sa-chat" data-i="${i}" data-goal="${it.goalId}" placeholder="Reply, ask, or send back to the AI…"><button class="sa-send" data-saact="send:${i}" title="Send"><svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button></div><button class="sa-ic" title="Archive" data-saact="arch:${i}">${SA_SVG.arch}</button><button class="sa-ic" title="Revert" data-saact="rev:${i}">${SA_SVG.trash}</button></div>
|
|
8577
8977
|
</div>`;
|
|
8578
8978
|
}
|
|
8579
8979
|
// Scroll the See-all body so a card's top edge (.rhead) sits at the top of the visible
|
|
@@ -8603,6 +9003,25 @@ async function saLoadActivityLog(taskId){
|
|
|
8603
9003
|
? lines.map((l) => saLogRowHtml(l)).join('')
|
|
8604
9004
|
: '<div class="txr res"><span class="w"></span><span class="m">Nothing was logged for this one.</span></div>';
|
|
8605
9005
|
}
|
|
9006
|
+
// Fetch one goal's conversation and paint it into the open card. Cached in SA.msgs
|
|
9007
|
+
// (an empty result included) so opening and closing a card doesn't ask twice. A failed
|
|
9008
|
+
// request writes an honest line instead of an empty box — the same rule the
|
|
9009
|
+
// implementation log follows.
|
|
9010
|
+
async function saLoadMessages(goalId){
|
|
9011
|
+
if (!Number.isFinite(goalId) || SA.msgs[goalId] !== undefined) return;
|
|
9012
|
+
SA.msgs[goalId] = null; // in flight — a second toggle must not re-fetch
|
|
9013
|
+
let msgs = [];
|
|
9014
|
+
try {
|
|
9015
|
+
const r = await fetch(withKey(`/api/goals/${goalId}/messages`));
|
|
9016
|
+
msgs = r.ok ? ((await r.json()).messages ?? []) : [];
|
|
9017
|
+
} catch { msgs = []; }
|
|
9018
|
+
SA.msgs[goalId] = msgs;
|
|
9019
|
+
const body = document.querySelector(`#seeall [data-msgbody="${goalId}"]`);
|
|
9020
|
+
if (!body) return;
|
|
9021
|
+
body.innerHTML = msgs.length
|
|
9022
|
+
? saMsgRowsHtml(msgs, SA.convOpen.has(goalId))
|
|
9023
|
+
: '<div class="txr res"><span class="w"></span><span class="m">Nothing was said on this one.</span></div>';
|
|
9024
|
+
}
|
|
8606
9025
|
// After first paint, compute the red box for any card showing a before/after pair and
|
|
8607
9026
|
// patch that card's node directly — a full re-render here would close every open
|
|
8608
9027
|
// pulldown just to draw a box. Cached per goal (null included) so it runs once.
|
|
@@ -8630,6 +9049,7 @@ function saRender(){
|
|
|
8630
9049
|
// Native <details> would collapse on every rebuild (SSE tick, approve, etc.) —
|
|
8631
9050
|
// track what's open per goal+section in SA.detOpen (persists across re-renders)
|
|
8632
9051
|
// and wire each one's toggle to keep that set current.
|
|
9052
|
+
paintReplyNote(); // the answer/question under a card's reply box survives its rebuild
|
|
8633
9053
|
for (const det of S.querySelectorAll('[data-detkey]')) {
|
|
8634
9054
|
det.addEventListener('toggle', () => {
|
|
8635
9055
|
const k = det.dataset.detkey;
|
|
@@ -8638,10 +9058,12 @@ function saRender(){
|
|
|
8638
9058
|
// "開いた時だけ取りに行く"). Closed cards cost nothing; a reopened one is served
|
|
8639
9059
|
// from SA.actLog.
|
|
8640
9060
|
if (det.open && det.dataset.actlog) saLoadActivityLog(Number(det.dataset.actlog));
|
|
9061
|
+
if (det.open && det.dataset.msgs) saLoadMessages(Number(det.dataset.msgs));
|
|
8641
9062
|
});
|
|
8642
9063
|
// A card re-rendered while its Activity was already open (SSE tick) keeps the log
|
|
8643
9064
|
// it had — the fetch below no-ops once SA.actLog holds the answer.
|
|
8644
9065
|
if (det.open && det.dataset.actlog) saLoadActivityLog(Number(det.dataset.actlog));
|
|
9066
|
+
if (det.open && det.dataset.msgs) saLoadMessages(Number(det.dataset.msgs));
|
|
8645
9067
|
}
|
|
8646
9068
|
// "ないものは載せない" (Masa): a Proof <img> whose file is missing/broken must never
|
|
8647
9069
|
// show a broken-image placeholder. proofImg is only a stored filename, so a file that
|
|
@@ -8760,6 +9182,49 @@ async function saCall(ids, path, body){
|
|
|
8760
9182
|
}
|
|
8761
9183
|
return r;
|
|
8762
9184
|
}
|
|
9185
|
+
// The review card's reply box (§1.3/§1.4). It says "Reply, ask, or send back" —
|
|
9186
|
+
// until now all three did the same thing: sent the goal back to be redone.
|
|
9187
|
+
//
|
|
9188
|
+
// Unlike saAct this does NOT show its outcome first. "Feedback sent — returned to
|
|
9189
|
+
// To Do" is a lie when the reply was a question, and an optimistic lie is worse
|
|
9190
|
+
// than a moment of waiting: the card would say the work was sent back while the
|
|
9191
|
+
// goal sat untouched in review.
|
|
9192
|
+
async function saReplyCall(it, text, outcome){
|
|
9193
|
+
const r = await saCall(it.ids, 'dismiss', { text, outcome });
|
|
9194
|
+
if (!r?.ok) throw new Error('reply failed');
|
|
9195
|
+
return r.json().catch(() => null);
|
|
9196
|
+
}
|
|
9197
|
+
async function saSendReply(it, i, text){
|
|
9198
|
+
const goalId = it.goalId;
|
|
9199
|
+
// By goal, not by row index: the list re-sorts while the AI is thinking, and an
|
|
9200
|
+
// index-based anchor then points at someone else's card (or nothing).
|
|
9201
|
+
const sel = `.sa-chat[data-goal="${goalId}"]`;
|
|
9202
|
+
const seen = state.goals.find((g) => g.id === goalId)?.messageCount ?? 0;
|
|
9203
|
+
it.inflight = true; saRender();
|
|
9204
|
+
showReplySent(sel, text, goalId); // BEFORE the round trip — this is the ~7s that felt broken
|
|
9205
|
+
try {
|
|
9206
|
+
let body = await saReplyCall(it, text, 'auto');
|
|
9207
|
+
if (body?.outcome === 'unsure') {
|
|
9208
|
+
it.inflight = false; saRender();
|
|
9209
|
+
const picked = await askReplyDestination(sel, body.choices);
|
|
9210
|
+
if (!picked) return; // nothing was touched; the words stay in the box
|
|
9211
|
+
it.inflight = true; saRender();
|
|
9212
|
+
body = await saReplyCall(it, text, picked);
|
|
9213
|
+
}
|
|
9214
|
+
it.inflight = false;
|
|
9215
|
+
if (body?.outcome === 'answer') {
|
|
9216
|
+
// The goal never moved, so the card stays exactly where it is — and the
|
|
9217
|
+
// answer appears under the box that asked for it.
|
|
9218
|
+
saRender();
|
|
9219
|
+
showAnswerWhenItArrives(sel, goalId, seen + 1);
|
|
9220
|
+
return;
|
|
9221
|
+
}
|
|
9222
|
+
it.st = 3; saSync(); saAdvanceFocus(goalId);
|
|
9223
|
+
} catch {
|
|
9224
|
+
it.inflight = false; saRender();
|
|
9225
|
+
showErr('Action failed.');
|
|
9226
|
+
}
|
|
9227
|
+
}
|
|
8763
9228
|
// one action round-trip: optimistic one-liner → endpoint → settle or roll back
|
|
8764
9229
|
async function saAct(it, st, path, body){
|
|
8765
9230
|
it.st = st; it.inflight = true; saRender();
|
|
@@ -8776,6 +9241,18 @@ async function saAct(it, st, path, body){
|
|
|
8776
9241
|
const SA_UNDO = { 1: 'revert-review', 2: 'reopen', 3: 'undismiss', 4: 'unarchive' }; // 07-20c: st 2 = Reject, undo = reopen
|
|
8777
9242
|
$('seeall').addEventListener('mousedown', (e) => { if (e.target === $('seeall')) saClose(); }); // no-dim dialog: outside = close
|
|
8778
9243
|
$('seeall').addEventListener('click', async (e) => {
|
|
9244
|
+
// "+N more" in a conversation: show the whole thing, in place. Nothing was summarised
|
|
9245
|
+
// away, so this only has to stop hiding it (§B3).
|
|
9246
|
+
const more = e.target.closest('.convmore');
|
|
9247
|
+
if (more) {
|
|
9248
|
+
const gid = Number(more.closest('[data-msgbody]')?.dataset.msgbody);
|
|
9249
|
+
if (Number.isFinite(gid)) {
|
|
9250
|
+
SA.convOpen.add(gid);
|
|
9251
|
+
const body = document.querySelector(`#seeall [data-msgbody="${gid}"]`);
|
|
9252
|
+
if (body) body.innerHTML = saMsgRowsHtml(SA.msgs[gid], true);
|
|
9253
|
+
}
|
|
9254
|
+
return;
|
|
9255
|
+
}
|
|
8779
9256
|
const b = e.target.closest('[data-saact]');
|
|
8780
9257
|
if (!b) return;
|
|
8781
9258
|
const [k, v] = b.dataset.saact.split(':'); const i = Number(v);
|
|
@@ -8826,7 +9303,7 @@ $('seeall').addEventListener('click', async (e) => {
|
|
|
8826
9303
|
const inp = $('seeall').querySelector(`.sa-chat[data-i="${i}"]`);
|
|
8827
9304
|
const text = inp?.value.trim();
|
|
8828
9305
|
if (!text) { inp?.focus(); return; }
|
|
8829
|
-
return
|
|
9306
|
+
return saSendReply(it, i, text);
|
|
8830
9307
|
}
|
|
8831
9308
|
if (k === 'undo') {
|
|
8832
9309
|
const path = SA_UNDO[it.st];
|
|
@@ -8844,7 +9321,7 @@ $('seeall').addEventListener('keydown', (e) => {
|
|
|
8844
9321
|
if (!text) return;
|
|
8845
9322
|
e.preventDefault();
|
|
8846
9323
|
const it = SA.items[Number(e.target.dataset.i)];
|
|
8847
|
-
if (it)
|
|
9324
|
+
if (it) saSendReply(it, Number(e.target.dataset.i), text); // no extra confirm (PRD §5.1): Enter = sent
|
|
8848
9325
|
});
|
|
8849
9326
|
document.addEventListener('keydown', (e) => {
|
|
8850
9327
|
if (!SA.open || ['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
|
|
@@ -8921,6 +9398,115 @@ setAgentEffort(state.connectedApp);
|
|
|
8921
9398
|
renderAppSelect();
|
|
8922
9399
|
renderModelSelect();
|
|
8923
9400
|
renderEffortSelect();
|
|
9401
|
+
// ---- Context Bar: the folder this project works in --------------------------
|
|
9402
|
+
// A browser cannot open an OS folder dialog that returns a real path, so the choice is
|
|
9403
|
+
// the repos the engine detected (GET /api/repos) plus a typed path — the same two-part
|
|
9404
|
+
// shape the "which folder?" question card has always used. Rendered with the composer's
|
|
9405
|
+
// own dropdown vocabulary (.appmenu/.appopt); no new component.
|
|
9406
|
+
// Mirrors engine/lib.mjs folderName() — the browser cannot import it, and the engine
|
|
9407
|
+
// test pins them to the same answers so the two can't drift apart.
|
|
9408
|
+
function folderName(dir){
|
|
9409
|
+
const d = String(dir ?? '').replace(/\/+$/, '');
|
|
9410
|
+
return d ? d.split('/').pop() : '';
|
|
9411
|
+
}
|
|
9412
|
+
function folderShort(dir){
|
|
9413
|
+
const home = String(state.repoHome || '');
|
|
9414
|
+
const d = String(dir || '');
|
|
9415
|
+
return home && d.startsWith(home) ? `~${d.slice(home.length)}` : d;
|
|
9416
|
+
}
|
|
9417
|
+
function renderCtxBar(){
|
|
9418
|
+
const btn = $('ctxdirnow'); if (!btn) return;
|
|
9419
|
+
const proj = state.projects.find((p) => p.id === state.active);
|
|
9420
|
+
const dir = String(proj?.dir ?? '');
|
|
9421
|
+
// The repo's NAME, the way Claude prints it — the full path lives in the menu and the
|
|
9422
|
+
// tooltip. No folder yet is a real state, not a blank: say so rather than render empty.
|
|
9423
|
+
// folderName is shared with the menu rows below (engine/lib.mjs): two copies of "take
|
|
9424
|
+
// the last segment" is how a chip and its own menu start disagreeing.
|
|
9425
|
+
btn.textContent = dir ? (folderName(dir) || folderShort(dir)) : 'choose a folder';
|
|
9426
|
+
$('ctxdirbtn').title = dir || 'Choose the folder this project works in';
|
|
9427
|
+
// Branch: only when the engine actually read one off the folder (a folder that is not
|
|
9428
|
+
// a repo has none, and an invented branch would be worse than no chip at all).
|
|
9429
|
+
const branch = String(proj?.branch ?? '');
|
|
9430
|
+
const grp = $('ctxbrgrp');
|
|
9431
|
+
if (grp) grp.style.display = branch ? '' : 'none';
|
|
9432
|
+
const bn = $('ctxbranchname'); if (bn) bn.textContent = branch;
|
|
9433
|
+
renderWorktreeChip();
|
|
9434
|
+
const menu = $('ctxdirmenu'); if (!menu) return;
|
|
9435
|
+
const cur = dir;
|
|
9436
|
+
const opts = (state.repos ?? [])
|
|
9437
|
+
.map((r) => `<button type="button" class="appopt${r.dir === cur ? ' active' : ''}" data-ctxdir="${esc(r.dir)}"`
|
|
9438
|
+
+ ` title="${esc(r.dir)}"><span class="nm">${esc(folderName(r.dir))}</span>`
|
|
9439
|
+
// LRM, so the rtl clipping trims the HEAD of the path without reversing the text
|
|
9440
|
+
+ `<span class="pt">\u200e${esc(folderShort(r.dir))}</span></button>`)
|
|
9441
|
+
.join('');
|
|
9442
|
+
menu.innerHTML = `${opts}<div class="ctxpath"><input type="text" id="ctxdirin" placeholder="~/path/to/repo" aria-label="Folder path" spellcheck="false"><button type="button" class="appopt" id="ctxdiruse" style="width:auto">Use</button></div><div class="ctxmsg" id="ctxdirmsg"></div>`;
|
|
9443
|
+
}
|
|
9444
|
+
// The worktree checkbox (engine #268). ON — the default, and what every goal has always
|
|
9445
|
+
// done — gives each goal its own copy of the repo, so a run cannot leave this folder
|
|
9446
|
+
// half-edited. OFF sends worktree:false and the work happens right here, which is what
|
|
9447
|
+
// someone means when they want to watch it happen. Sticky per project, like model and
|
|
9448
|
+
// effort, because it is a working habit rather than a per-message thought.
|
|
9449
|
+
function worktreeKey(){ return `sel:worktree:${state.active}`; }
|
|
9450
|
+
function worktreeOn(){ return localStorage.getItem(worktreeKey()) !== '0'; }
|
|
9451
|
+
function renderWorktreeChip(){
|
|
9452
|
+
const el = $('ctxwt'); if (!el) return;
|
|
9453
|
+
el.setAttribute('aria-checked', worktreeOn() ? 'true' : 'false');
|
|
9454
|
+
}
|
|
9455
|
+
// Asked for once, and only when the menu is first opened — the same "only when you look
|
|
9456
|
+
// at it" rule the implementation log and the conversation follow.
|
|
9457
|
+
async function loadRepos(){
|
|
9458
|
+
if (state.repos !== null) return;
|
|
9459
|
+
state.repos = [];
|
|
9460
|
+
try {
|
|
9461
|
+
const r = await fetch(withKey('/api/repos'));
|
|
9462
|
+
const body = r.ok ? await r.json() : {};
|
|
9463
|
+
state.repos = Array.isArray(body.repos) ? body.repos : [];
|
|
9464
|
+
state.repoHome = String(body.home ?? '');
|
|
9465
|
+
} catch { /* no list — the typed path still works, which is the honest fallback */ }
|
|
9466
|
+
renderCtxBar();
|
|
9467
|
+
}
|
|
9468
|
+
// Move this project. The engine refuses a folder where work cannot happen and says
|
|
9469
|
+
// which; that sentence is shown as-is, because "it didn't work" without the reason is
|
|
9470
|
+
// the failure this control exists to prevent.
|
|
9471
|
+
async function setProjectFolder(dir){
|
|
9472
|
+
const want = String(dir ?? '').trim();
|
|
9473
|
+
const msg = $('ctxdirmsg');
|
|
9474
|
+
const say = (t, bad) => { if (msg) { msg.textContent = t; msg.classList.toggle('bad', Boolean(bad)); } };
|
|
9475
|
+
if (!want) { say('Type a path, or pick one above.', true); return; }
|
|
9476
|
+
say('');
|
|
9477
|
+
try {
|
|
9478
|
+
const r = await fetch(withKey(`/api/projects/${encodeURIComponent(state.active)}`), {
|
|
9479
|
+
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ dir: want }),
|
|
9480
|
+
});
|
|
9481
|
+
const body = await r.json().catch(() => ({}));
|
|
9482
|
+
if (!r.ok) { say(body.error || 'That folder cannot be used.', true); return; }
|
|
9483
|
+
if (Array.isArray(body.projects)) state.projects = body.projects;
|
|
9484
|
+
$('ctxdirmenu').classList.remove('show');
|
|
9485
|
+
render();
|
|
9486
|
+
} catch { say('Could not reach the engine.', true); }
|
|
9487
|
+
}
|
|
9488
|
+
$('ctxdirbtn').onclick = (e) => {
|
|
9489
|
+
e.stopPropagation();
|
|
9490
|
+
const menu = $('ctxdirmenu');
|
|
9491
|
+
const opening = !menu.classList.contains('show');
|
|
9492
|
+
menu.classList.toggle('show');
|
|
9493
|
+
if (opening) { renderCtxBar(); loadRepos(); } // no-op once boot's fetch has landed
|
|
9494
|
+
};
|
|
9495
|
+
$('ctxwt').onclick = (e) => {
|
|
9496
|
+
e.stopPropagation();
|
|
9497
|
+
localStorage.setItem(worktreeKey(), worktreeOn() ? '0' : '1');
|
|
9498
|
+
renderWorktreeChip();
|
|
9499
|
+
};
|
|
9500
|
+
$('ctxdirmenu').addEventListener('click', (e) => {
|
|
9501
|
+
e.stopPropagation();
|
|
9502
|
+
const opt = e.target.closest('[data-ctxdir]');
|
|
9503
|
+
if (opt) { setProjectFolder(opt.dataset.ctxdir); return; }
|
|
9504
|
+
if (e.target.closest('#ctxdiruse')) setProjectFolder($('ctxdirin')?.value ?? '');
|
|
9505
|
+
});
|
|
9506
|
+
$('ctxdirmenu').addEventListener('keydown', (e) => {
|
|
9507
|
+
e.stopPropagation();
|
|
9508
|
+
if (e.key === 'Enter' && e.target.id === 'ctxdirin') { e.preventDefault(); setProjectFolder(e.target.value); }
|
|
9509
|
+
});
|
|
8924
9510
|
$('appbtn').onclick = (e) => { e.stopPropagation(); $('appmenu').classList.toggle('show'); };
|
|
8925
9511
|
$('appmenu').addEventListener('click', (e) => {
|
|
8926
9512
|
const opt = e.target.closest('.appopt');
|
|
@@ -8940,6 +9526,10 @@ $('appmenu').addEventListener('click', (e) => {
|
|
|
8940
9526
|
});
|
|
8941
9527
|
document.addEventListener('click', (e) => {
|
|
8942
9528
|
if (!e.target.closest('.appsel')) $('appmenu').classList.remove('show');
|
|
9529
|
+
// Same rule for the folder menu: a click anywhere else closes it (§: settings-style
|
|
9530
|
+
// popovers close on outside tap — measured against composedPath, not target alone,
|
|
9531
|
+
// so a click inside the menu's own input never closes it).
|
|
9532
|
+
if (!e.composedPath().some((n) => n?.id === 'ctxdirsel')) $('ctxdirmenu').classList.remove('show');
|
|
8943
9533
|
});
|
|
8944
9534
|
$('modelbtn').onclick = (e) => { e.stopPropagation(); $('modelmenu').classList.toggle('show'); };
|
|
8945
9535
|
$('modelmenu').addEventListener('click', (e) => {
|
|
@@ -9944,8 +10534,44 @@ function gearSetHTML(){
|
|
|
9944
10534
|
<button class="optchip${lang === 'en' ? ' on' : ''}" data-gslang="en">EN</button>
|
|
9945
10535
|
<button class="optchip${lang === 'ja' ? ' on' : ''}" data-gslang="ja">JA</button></div>
|
|
9946
10536
|
${billingRow}
|
|
10537
|
+
${gearRulesHTML()}
|
|
9947
10538
|
<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>`;
|
|
9948
10539
|
}
|
|
10540
|
+
// ONE-CONVERSATION §1.5a: the project's working rules — the file(s) the connected
|
|
10541
|
+
// AI actually reads (Claude Code → CLAUDE.md, Codex → AGENTS.md). Read-only: Galda
|
|
10542
|
+
// does NOT own these rules, it only surfaces them (editing is done in an editor or
|
|
10543
|
+
// by asking the AI in chat). §3 段階開示 — when the project has no such file the row
|
|
10544
|
+
// is absent entirely (never an empty container; the "ask once" flow is §1.5b).
|
|
10545
|
+
function gearRulesHTML(){
|
|
10546
|
+
const r = state.projectRules[state.active];
|
|
10547
|
+
if (!r || !r.files || !r.files.length) return '';
|
|
10548
|
+
const names = r.files.map((f) => f.name).join(' · ');
|
|
10549
|
+
const view = state.rulesOpen
|
|
10550
|
+
? `<div class="rulesview">${r.files.map((f) =>
|
|
10551
|
+
`<div class="rulesfile"><div class="rulesname">${esc(f.name)}</div>`
|
|
10552
|
+
+ `<pre class="rulestext">${esc(f.content) || '<span class="rulesempty">(empty file)</span>'}</pre></div>`).join('')}</div>`
|
|
10553
|
+
: '';
|
|
10554
|
+
return `
|
|
10555
|
+
<div class="srow"><div class="sl">Project rules<span class="sd">What this project tells the AI — read-only · ${esc(names)}</span></div>
|
|
10556
|
+
<button class="optchip${state.rulesOpen ? ' on' : ''}" data-grules="1">${state.rulesOpen ? 'Hide' : 'View'}</button></div>
|
|
10557
|
+
${view}`;
|
|
10558
|
+
}
|
|
10559
|
+
// Fetch the active project's rules files once (cached per project), then rebuild
|
|
10560
|
+
// the settings panel so the row appears. The browser can't read the host FS, so
|
|
10561
|
+
// this is the only way in — GET /api/projects/:id/rules (engine, §1.5a).
|
|
10562
|
+
let rulesFetching = null;
|
|
10563
|
+
async function loadProjectRules(){
|
|
10564
|
+
const pid = state.active;
|
|
10565
|
+
if (state.projectRules[pid] || rulesFetching === pid) return;
|
|
10566
|
+
rulesFetching = pid;
|
|
10567
|
+
try {
|
|
10568
|
+
const r = await fetch(withKey(`/api/projects/${pid}/rules`));
|
|
10569
|
+
state.projectRules[pid] = r.ok ? await r.json() : { files: [] };
|
|
10570
|
+
} catch { state.projectRules[pid] = { files: [] }; }
|
|
10571
|
+
finally { rulesFetching = null; }
|
|
10572
|
+
// Only rebuild if the settings tab is still the one on screen.
|
|
10573
|
+
if ($('gearPop') && custTab === 'settings') buildGearPop();
|
|
10574
|
+
}
|
|
9949
10575
|
// MCP (v46): connection status dot + connectCommand + COPY + quiet explanation.
|
|
9950
10576
|
// The dot is the app's own live engine link (SSE up/down) — the only real signal here.
|
|
9951
10577
|
function gearMcpHTML(){
|
|
@@ -9996,6 +10622,9 @@ function buildGearPop(){
|
|
|
9996
10622
|
try { await fetch(withKey('/api/signout'), { method: 'POST' }); recentlySignedOut = true; }
|
|
9997
10623
|
finally { await refresh(); if ($('gearPop')) buildGearPop(); }
|
|
9998
10624
|
};
|
|
10625
|
+
const rulesBtn = pop.querySelector('[data-grules]');
|
|
10626
|
+
if (rulesBtn) rulesBtn.onclick = () => { state.rulesOpen = !state.rulesOpen; buildGearPop(); };
|
|
10627
|
+
loadProjectRules();
|
|
9999
10628
|
return;
|
|
10000
10629
|
}
|
|
10001
10630
|
if (custTab === 'mcp') {
|
|
@@ -10129,6 +10758,7 @@ function custPlace(){ // flagship: top-anchored band beside the rail (v45 §2);
|
|
|
10129
10758
|
}
|
|
10130
10759
|
function custOpen(tab){
|
|
10131
10760
|
custTab = tab || 'theme'; // v46 deep-link (flagship __custOpen(tab)): menu entries land on their tab
|
|
10761
|
+
state.rulesOpen = false; // rules panel starts collapsed each time the gear opens
|
|
10132
10762
|
custSnap = { cust: { ...cust }, theme: state.theme, band: fsUI.band };
|
|
10133
10763
|
buildGearPop(); custPlace();
|
|
10134
10764
|
$('gearPop').classList.add('open');
|