@galda/cli 0.10.17 → 0.10.20
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 +43 -1
- package/app/index.html +376 -62
- package/bin/manager-for-ai.mjs +18 -12
- package/engine/lib.mjs +492 -12
- package/engine/relay-client.mjs +137 -32
- package/engine/server.mjs +445 -21
- package/package.json +3 -3
package/app/index.html
CHANGED
|
@@ -610,10 +610,14 @@
|
|
|
610
610
|
.confetti-piece{position:fixed;pointer-events:none;z-index:9999;will-change:transform,opacity}
|
|
611
611
|
|
|
612
612
|
/* connect modal */
|
|
613
|
-
.
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
613
|
+
/* A confirm dialog (deleteConfirmOverlay etc.) must always sit above other
|
|
614
|
+
floating chrome it can be opened from — was 60, then 90 (cleared .gearpop's
|
|
615
|
+
80 for the customize-panel discard confirm), but that still sat under the
|
|
616
|
+
context menu (95) and the settings panel rendered in front of its own Log
|
|
617
|
+
out confirm (Masa 指示 2026-07-16). 100 clears every other floating layer
|
|
618
|
+
(gearpop 80, context menu 95) while staying under true full-screen gates
|
|
619
|
+
(lightbox 200, #connectGate 1000). */
|
|
620
|
+
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);backdrop-filter:blur(6px);z-index:100;display:none;place-items:center}
|
|
617
621
|
.overlay.show{display:grid}
|
|
618
622
|
.modal{width:640px;max-width:92vw;background:var(--surface);border:1px solid var(--hair2);border-radius:16px;padding:20px 22px;box-shadow:0 24px 70px rgba(0,0,0,.6)}
|
|
619
623
|
.modal h3{margin:0 0 6px;font-size:15px;letter-spacing:-.01em}
|
|
@@ -634,6 +638,10 @@
|
|
|
634
638
|
.mbtn.danger{background:color-mix(in srgb,var(--danger) 10%,var(--panel));color:color-mix(in srgb,var(--danger) 78%,var(--ink));
|
|
635
639
|
box-shadow:inset 0 0 0 1px rgba(var(--danger-rgb),.35)}
|
|
636
640
|
.mbtn.danger:hover{box-shadow:inset 0 0 0 1px var(--danger);background:color-mix(in srgb,var(--danger) 16%,var(--panel))}
|
|
641
|
+
/* Neutral confirm (not destructive/AI — e.g. Log out): same ink-solid tone as
|
|
642
|
+
the settings Save button (.cfoot .cbtn.save), not the iri default (AI-only, §5.5). */
|
|
643
|
+
.mbtn.solid{background:var(--ink);color:var(--invink)}
|
|
644
|
+
.mbtn.solid:hover{opacity:.9}
|
|
637
645
|
|
|
638
646
|
/* Slack-style centered delete confirmation (replaces native confirm() — restores a
|
|
639
647
|
decision lost in the flagship rewrite; see docs/confirmed-behaviors.md). Deliberately
|
|
@@ -980,7 +988,7 @@
|
|
|
980
988
|
.gearbtn{display:grid;place-items:center;width:30px;height:28px;border:1px solid var(--hair2);border-radius:8px;background:transparent;color:var(--ink2);cursor:pointer}
|
|
981
989
|
.gearbtn:hover{background:var(--surface2);color:var(--ink)}
|
|
982
990
|
.gearbtn svg{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
|
|
983
|
-
.gearpop{display:none;position:absolute;top:36px;right:0;z-index:80;background:var(--panel);border-radius:12px;box-shadow:0 10px 30px rgba(20,23,31,.18),0 0 0 1px var(--hair);padding:12px 13px;width:
|
|
991
|
+
.gearpop{display:none;position:absolute;top:36px;right:0;z-index:80;background:var(--panel);border-radius:12px;box-shadow:0 10px 30px rgba(20,23,31,.18),0 0 0 1px var(--hair);padding:12px 13px;width:320px;
|
|
984
992
|
max-height:78vh;overflow-y:auto;scrollbar-width:thin; /* TB edge + colour sections made it taller than the viewport */
|
|
985
993
|
/* opaque chip fill (HANDOFF-v45 §9): a translucent --canvas would let the wallpaper
|
|
986
994
|
bleed through and colour each row differently — this token mixes off the OPAQUE
|
|
@@ -1017,7 +1025,7 @@
|
|
|
1017
1025
|
the rail (left is set on open from the measured rail edge), max-height = viewport −
|
|
1018
1026
|
margins, internal scroll. White-translucent per §3: color-mix 74% + blur28 (theme-tinted).
|
|
1019
1027
|
The node is adopted to <body> in flagship (positionLayout) — .top is display:none there. */
|
|
1020
|
-
body[data-layout="flagship"] .gearpop{position:fixed;top:120px;right:auto;width:
|
|
1028
|
+
body[data-layout="flagship"] .gearpop{position:fixed;top:120px;right:auto;width:380px;max-height:calc(100vh - 160px);border-radius:14px;
|
|
1021
1029
|
background:color-mix(in srgb,var(--panel) 74%,transparent);
|
|
1022
1030
|
-webkit-backdrop-filter:blur(28px) saturate(1.35);backdrop-filter:blur(28px) saturate(1.35)}
|
|
1023
1031
|
/* settings = EDIT SESSION (HANDOFF-v45 §3, flagship .cfoot/.mcpbox port): sticky footer,
|
|
@@ -1267,6 +1275,29 @@
|
|
|
1267
1275
|
#fsRoot .fsstream::-webkit-scrollbar{display:none}
|
|
1268
1276
|
#fsFeed{display:flex;flex-direction:column;gap:8px;padding-top:2px}
|
|
1269
1277
|
#fsRoot .cc-say{font-size:13.5px;color:var(--ink)}
|
|
1278
|
+
/* Pattern A (H23 / AMENDMENT-center-feed-2026-07-16, CDO): the center feed is
|
|
1279
|
+
a conversation — the user's own turns live here too, not just Claude Code's.
|
|
1280
|
+
器を増やさない(§1.5-1): no bubbles — a speaker label + a hairline turn border. */
|
|
1281
|
+
#fsRoot .cc-you{padding-top:11px;border-top:1px solid var(--hair)}
|
|
1282
|
+
#fsRoot .cc-you:first-child{padding-top:0;border-top:0}
|
|
1283
|
+
#fsRoot .cc-you .cy-n{display:block;font:600 8.5px/1 var(--mono);letter-spacing:.13em;text-transform:uppercase;color:var(--ink3);margin-bottom:5px}
|
|
1284
|
+
#fsRoot .cc-you .cy-t{font-size:13.5px;color:var(--ink);line-height:1.45}
|
|
1285
|
+
/* Typing cursor on the streaming reply frontier (rides the client-side reveal —
|
|
1286
|
+
the CLI returns the reply as one block, not token deltas). Solid block, hard
|
|
1287
|
+
terminal blink (steps(1)); --ink adapts across all themes. */
|
|
1288
|
+
#fsRoot .cc-caret{display:inline-block;width:6px;height:14px;margin-left:2px;transform:translateY(2px);
|
|
1289
|
+
background:var(--ink);animation:ccblink 1.05s steps(1) infinite}
|
|
1290
|
+
@keyframes ccblink{50%{opacity:0}}
|
|
1291
|
+
@media (prefers-reduced-motion:reduce){#fsRoot .cc-caret{animation:none}}
|
|
1292
|
+
/* "→ Added to To Do #NN" — shown quietly in the flow when a turn became a task.
|
|
1293
|
+
Undo appears on hover only (§1.5-10). No filled surface. */
|
|
1294
|
+
#fsRoot .cc-mark{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:11px;color:var(--ink3);padding:1px 0}
|
|
1295
|
+
#fsRoot .cc-mark .cm-ar{color:var(--ink2);font-size:12.5px;line-height:1}
|
|
1296
|
+
#fsRoot .cc-mark .cm-t{color:var(--ink2)}
|
|
1297
|
+
#fsRoot .cc-mark .cm-no{color:var(--ink3);font-variant-numeric:tabular-nums}
|
|
1298
|
+
#fsRoot .cc-mark .cm-undo{margin-left:2px;padding:0;border:0;background:none;font:inherit;color:var(--ink3);cursor:pointer;opacity:0;transition:opacity .15s,color .15s}
|
|
1299
|
+
#fsRoot .cc-mark:hover .cm-undo{opacity:1}
|
|
1300
|
+
#fsRoot .cc-mark .cm-undo:hover{color:var(--blue)}
|
|
1270
1301
|
/* Claude Code CLI language (HANDOFF-v45 §12, flagship .cc-t port): gap:0 — the bullet's
|
|
1271
1302
|
own margin-right does the spacing, not a flex gap (keeps ⏺ tight against the tool name
|
|
1272
1303
|
the way the real CLI reads). */
|
|
@@ -1326,6 +1357,10 @@
|
|
|
1326
1357
|
#fsRoot .fs-askbar{display:none;border:1px solid var(--hair2);border-bottom:0;border-left:2px solid var(--blue);border-radius:14px 14px 0 0;padding:9px 13px 11px} /* 枠1px (v3 §6.5) */
|
|
1327
1358
|
#fsRoot .compwrap{max-width:var(--fscenterw,760px);width:100%;margin:10px auto 0;flex:0 0 auto;position:relative;z-index:1;
|
|
1328
1359
|
transition:max-width .28s cubic-bezier(.4,0,.2,1)}
|
|
1360
|
+
/* #errbar is adopted into .compwrap by positionLayout() (flagship hides .main
|
|
1361
|
+
outright, its original home) — float it just above the input, matching the
|
|
1362
|
+
legacy layout's "just above the composer" placement (CDO判定 2026-07-16). */
|
|
1363
|
+
#fsRoot .compwrap>.errbar{position:absolute;left:50%;bottom:calc(100% + 10px);transform:translateX(-50%)}
|
|
1329
1364
|
#fsRoot.lanefold{--fscenterw:1020px} /* folded space flows to the center (§1.5-7) — one var flip keeps feed+composer in lockstep */
|
|
1330
1365
|
/* adopted composer (#composerWrap moves in — same node, same handlers, focus fix intact) */
|
|
1331
1366
|
body[data-layout="flagship"] #composerWrap{padding:0;border-top:0;max-width:none;width:auto;margin:0}
|
|
@@ -2052,6 +2087,11 @@
|
|
|
2052
2087
|
.bx-lb{font:600 9px/1 var(--mono);letter-spacing:.11em;text-transform:uppercase;color:var(--red)}
|
|
2053
2088
|
.bx-q{font-size:13px;color:var(--ink);margin-top:4px}
|
|
2054
2089
|
.bx-act{flex:none}
|
|
2090
|
+
/* neutral ask (not a limit/error — e.g. the sign-in nudge): same card, --blue
|
|
2091
|
+
accent instead of --red, matching the existing AI-confirmation blue
|
|
2092
|
+
(§7決定ログ「AI確認:色は青系」) rather than inventing a new color. */
|
|
2093
|
+
.bx-ask.info{border-left-color:var(--blue)}
|
|
2094
|
+
.bx-ask.info .bx-lb{color:var(--blue)}
|
|
2055
2095
|
/* ② post-checkout return bar */
|
|
2056
2096
|
.bx-cobar{box-shadow:inset 0 0 0 1px var(--hair2);border-radius:11px;padding:10px 13px;display:flex;align-items:center;gap:10px;font-size:12.5px;margin:0 auto 12px;width:100%;max-width:var(--fscenterw,760px)}
|
|
2057
2097
|
.bx-cobar[hidden]{display:none}
|
|
@@ -2097,7 +2137,7 @@
|
|
|
2097
2137
|
<button class="laybtn" data-theme="cinema">Cinema</button>
|
|
2098
2138
|
</div>
|
|
2099
2139
|
<div class="gearwrap">
|
|
2100
|
-
<button class="gearbtn" id="gearBtn" title="
|
|
2140
|
+
<button class="gearbtn" id="gearBtn" title="Settings" aria-label="Settings"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
|
|
2101
2141
|
<div class="gearpop" id="gearPop"></div>
|
|
2102
2142
|
</div>
|
|
2103
2143
|
<button class="pill-btn" id="connect">Connect MCP</button>
|
|
@@ -2210,9 +2250,8 @@
|
|
|
2210
2250
|
<div class="lbrow"><span class="lb">Projects</span>
|
|
2211
2251
|
<button class="ghostbtn morebtn" id="fsMoreBtn" title="Settings"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3.2"/><path d="M19.4 15a1.7 1.7 0 00.34 1.87l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.7 1.7 0 00-1.87-.34 1.7 1.7 0 00-1 1.56V21a2 2 0 11-4 0v-.09a1.7 1.7 0 00-1-1.56 1.7 1.7 0 00-1.87.34l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.7 1.7 0 00.34-1.87 1.7 1.7 0 00-1.56-1H3a2 2 0 110-4h.09a1.7 1.7 0 001.56-1 1.7 1.7 0 00-.34-1.87l-.06-.06a2 2 0 112.83-2.83l.06.06a1.7 1.7 0 001.87.34h.09a1.7 1.7 0 001-1.56V3a2 2 0 114 0v.09a1.7 1.7 0 001 1.56h.09a1.7 1.7 0 001.87-.34l.06-.06a2 2 0 112.83 2.83l-.06.06a1.7 1.7 0 00-.34 1.87v.09a1.7 1.7 0 001.56 1H21a2 2 0 110 4h-.09a1.7 1.7 0 00-1.56 1z"/></svg></button>
|
|
2212
2252
|
<div class="moremenu" id="fsMoreMenu">
|
|
2213
|
-
<button class="ghostbtn" id="fsCust">
|
|
2253
|
+
<button class="ghostbtn" id="fsCust">Theme</button>
|
|
2214
2254
|
<button class="ghostbtn" id="fsConn">Connect MCP</button>
|
|
2215
|
-
<button class="ghostbtn" id="fsTodoBd">To Do board</button>
|
|
2216
2255
|
<button class="ghostbtn" id="fsHist">History</button>
|
|
2217
2256
|
<button class="ghostbtn" id="fsSet">Settings</button>
|
|
2218
2257
|
</div>
|
|
@@ -2466,7 +2505,7 @@ const savedTheme = (() => {
|
|
|
2466
2505
|
if (v !== s) localStorage.setItem('theme', v);
|
|
2467
2506
|
return v;
|
|
2468
2507
|
})();
|
|
2469
|
-
const state = { projects: [], goals: [], tasks: [], act: {}, actAt: {}, todos: {}, active: 'galda2', 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, summaryPattern: Number(localStorage.getItem('summaryPattern')) || 3, peraSpot: 0, skill: null, skillsCache: null,
|
|
2508
|
+
const state = { projects: [], goals: [], tasks: [], act: {}, actAt: {}, todos: {}, chatTurns: {}, active: 'galda2', 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, summaryPattern: Number(localStorage.getItem('summaryPattern')) || 3, peraSpot: 0, skill: null, skillsCache: null,
|
|
2470
2509
|
// per-message send overrides set via slash-commands (reset after each send,
|
|
2471
2510
|
// except model which is sticky in localStorage). palette = open command list.
|
|
2472
2511
|
msg: { mode: null, model: null, effort: null, skill: null, later: false, review: false },
|
|
@@ -2474,6 +2513,28 @@ const state = { projects: [], goals: [], tasks: [], act: {}, actAt: {}, todos: {
|
|
|
2474
2513
|
window.state = state; // top-level const isn't a window property; external checks probe window.state
|
|
2475
2514
|
const $ = (id) => document.getElementById(id);
|
|
2476
2515
|
|
|
2516
|
+
// Question free-text answer boxes (qansinput/fsansinput) are rebuilt from
|
|
2517
|
+
// scratch (container.innerHTML = ...) on every SSE-driven render, which — same
|
|
2518
|
+
// class of bug as the composer's c32fda1 fix — detaches whatever the user is
|
|
2519
|
+
// mid-typing into and drops focus + cursor position + the in-progress text.
|
|
2520
|
+
// Since these nodes can't be kept alive like the composer (they're generated
|
|
2521
|
+
// HTML strings, not a persistent DOM subtree), save the focused box's value
|
|
2522
|
+
// and selection before the rebuild and restore them onto the freshly-rendered
|
|
2523
|
+
// node with the same data-attribute id afterward.
|
|
2524
|
+
function captureFocusedAnswerInput(container, attr){
|
|
2525
|
+
const el = document.activeElement;
|
|
2526
|
+
if (!el || !container || !container.contains(el) || !el.hasAttribute(attr)) return null;
|
|
2527
|
+
return { id: el.getAttribute(attr), value: el.value, start: el.selectionStart, end: el.selectionEnd };
|
|
2528
|
+
}
|
|
2529
|
+
function restoreFocusedAnswerInput(container, attr, saved){
|
|
2530
|
+
if (!saved || !container) return;
|
|
2531
|
+
const el = container.querySelector(`[${attr}="${saved.id}"]`);
|
|
2532
|
+
if (!el) return;
|
|
2533
|
+
el.value = saved.value;
|
|
2534
|
+
el.focus();
|
|
2535
|
+
try { el.setSelectionRange(saved.start, saved.end); } catch { /* input type may not support it */ }
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2477
2538
|
const STATUS_LABEL = {
|
|
2478
2539
|
sending: 'Sending…', stacked: 'Queued', planning: 'Planning…', queued: 'To do', running: 'Working…', needsInput: 'Needs input',
|
|
2479
2540
|
review: 'In review', done: 'Done', failed: 'Failed', partial: 'Partly failed', interrupted: 'Interrupted', skipped: 'Skipped',
|
|
@@ -2564,10 +2625,15 @@ function setAgentEffort(agent){
|
|
|
2564
2625
|
localStorage.setItem('sel:effort', state.stickyEffort);
|
|
2565
2626
|
}
|
|
2566
2627
|
function renderAppSelect(){
|
|
2567
|
-
|
|
2568
|
-
|
|
2628
|
+
// Only offer agents whose CLI is actually installed (state.agents comes from the
|
|
2629
|
+
// server = AVAILABLE_AGENTS). A claude-only machine never shows Codex; a
|
|
2630
|
+
// Codex-only one never shows Claude Code. Never render an empty picker.
|
|
2631
|
+
const apps = CONNECTED_APPS.filter((a) => (state.agents || []).includes(a.id));
|
|
2632
|
+
const list = apps.length ? apps : CONNECTED_APPS.slice(0, 1);
|
|
2633
|
+
if (!list.some((a) => a.id === state.connectedApp)) state.connectedApp = list[0].id;
|
|
2634
|
+
const app = list.find((a) => a.id === state.connectedApp) ?? list[0];
|
|
2569
2635
|
$('appbtn').innerHTML = `${app.icon}<span>${esc(app.label)}</span>${ICONS.caret}`;
|
|
2570
|
-
$('appmenu').innerHTML =
|
|
2636
|
+
$('appmenu').innerHTML = list.map((a) =>
|
|
2571
2637
|
`<button type="button" class="appopt${a.id === state.connectedApp ? ' active' : ''}" data-val="${a.id}">${a.icon}<span>${esc(a.label)}</span></button>`
|
|
2572
2638
|
).join('');
|
|
2573
2639
|
}
|
|
@@ -2779,6 +2845,14 @@ function buildReviewDigest({ goals, tasks } = {}){
|
|
|
2779
2845
|
// Mirrors engine/lib.mjs buildReviewSummary()/requirementSummaryLine() (task 96)
|
|
2780
2846
|
// — kept in sync manually since the browser script isn't a module. Used by both
|
|
2781
2847
|
// the review checklist top summary and the chat-side review导线.
|
|
2848
|
+
// task 6 (2026-07-16): mirrors engine/lib.mjs goalTokenHistory() — one
|
|
2849
|
+
// {weightedTokens, requirementCount} sample per finished goal (excluding the
|
|
2850
|
+
// one being summarized), oldest-first, for the token-savings baseline below.
|
|
2851
|
+
function goalTokenHistoryLocal(goals, tasks, excludeGoalId){
|
|
2852
|
+
return (goals ?? [])
|
|
2853
|
+
.filter((g) => g && g.id !== excludeGoalId && GOAL_COMPLETE_STATUSES.includes(g.status) && g.runTokens != null)
|
|
2854
|
+
.map((g) => ({ weightedTokens: g.runTokens, requirementCount: (tasks ?? []).filter((t) => t.goalId === g.id && !t.reply).length }));
|
|
2855
|
+
}
|
|
2782
2856
|
function buildReviewSummary({ goal, tasks, approvedN = 0 } = {}){
|
|
2783
2857
|
const reqs = (tasks ?? []).filter((t) => !t.reply);
|
|
2784
2858
|
const total = reqs.length;
|
|
@@ -2788,6 +2862,9 @@ function buildReviewSummary({ goal, tasks, approvedN = 0 } = {}){
|
|
|
2788
2862
|
const source = plan.length ? plan : reqs.map((t) => t.title);
|
|
2789
2863
|
const what = source.map((s) => String(s ?? '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 3).join(' / ');
|
|
2790
2864
|
const fallback = String(goal?.text ?? '').replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
2865
|
+
// History is scoped to the same project as this goal — a goal in a small
|
|
2866
|
+
// toy project shouldn't be judged against a large project's average.
|
|
2867
|
+
const sameProjectGoals = (state.goals || []).filter((g) => g.projectId === goal?.projectId);
|
|
2791
2868
|
return { approvedN, total, headline: `${approvedN} / ${total} approved`, what: what || fallback,
|
|
2792
2869
|
detail: `${done}/${total} done${proofs ? ` · proof ×${proofs}` : ''}`,
|
|
2793
2870
|
failureMemory: goal?.failureMemory?.length ? goal.failureMemory[goal.failureMemory.length - 1] : null,
|
|
@@ -2800,6 +2877,8 @@ function buildReviewSummary({ goal, tasks, approvedN = 0 } = {}){
|
|
|
2800
2877
|
usedCompact: goal?.executionPlan?.sessionMode === 'compact',
|
|
2801
2878
|
usedClear: Boolean(goal?.sessionClosedAt),
|
|
2802
2879
|
agentModel: goal?.model,
|
|
2880
|
+
requirementCount: total,
|
|
2881
|
+
history: goalTokenHistoryLocal(sameProjectGoals, state.tasks || [], goal?.id),
|
|
2803
2882
|
}) };
|
|
2804
2883
|
}
|
|
2805
2884
|
function requirementSummaryLine(task){
|
|
@@ -2867,28 +2946,65 @@ function usageBudgetTokensLocal(usage, cacheReadWeight = 0.1){
|
|
|
2867
2946
|
const weight = Number.isFinite(Number(cacheReadWeight)) ? Math.max(0, Number(cacheReadWeight)) : 0.1;
|
|
2868
2947
|
return Math.round((u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + ((u.cache_read_input_tokens ?? 0) * weight));
|
|
2869
2948
|
}
|
|
2870
|
-
|
|
2949
|
+
// task 6 (2026-07-16): mirrors engine/lib.mjs tokenBaselinePerRequirement() —
|
|
2950
|
+
// moving average of tokens-per-requirement over up to the last 20 finished
|
|
2951
|
+
// goals (needs at least 3 to be meaningful).
|
|
2952
|
+
const TOKEN_BASELINE_MIN_HISTORY = 3;
|
|
2953
|
+
const TOKEN_BASELINE_MAX_HISTORY = 20;
|
|
2954
|
+
function tokenBaselinePerRequirementLocal(history){
|
|
2955
|
+
const samples = (history ?? [])
|
|
2956
|
+
.map((h) => {
|
|
2957
|
+
if (typeof h === 'number') return h;
|
|
2958
|
+
const tokens = Number(h?.weightedTokens);
|
|
2959
|
+
const reqN = Math.max(1, Number(h?.requirementCount) || 1);
|
|
2960
|
+
return Number.isFinite(tokens) ? tokens / reqN : null;
|
|
2961
|
+
})
|
|
2962
|
+
.filter((n) => Number.isFinite(n) && n > 0)
|
|
2963
|
+
.slice(-TOKEN_BASELINE_MAX_HISTORY);
|
|
2964
|
+
if (samples.length < TOKEN_BASELINE_MIN_HISTORY) return null;
|
|
2965
|
+
return samples.reduce((a, b) => a + b, 0) / samples.length;
|
|
2966
|
+
}
|
|
2967
|
+
// Mirrors engine/lib.mjs estimateTokenOptimization() — see the comment there
|
|
2968
|
+
// for why "saved X%" now compares against a real per-project baseline
|
|
2969
|
+
// (basis: 'baseline') instead of always reusing this goal's own cache-hit
|
|
2970
|
+
// ratio as a stand-in for "cheaper than usual" (basis: 'cache-weight',
|
|
2971
|
+
// used only until there's enough finished-goal history).
|
|
2972
|
+
function estimateTokenOptimizationLocal({ usage = null, weightedTokens = null, cacheReadWeight = 0.1, sessionMode = null, usedHandoff = false, usedCompact = false, usedClear = false, agentModel = null, requirementCount = 1, history = [] } = {}){
|
|
2871
2973
|
const u = usage ?? {};
|
|
2872
2974
|
const raw = (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0);
|
|
2975
|
+
if (raw <= 0) return null;
|
|
2873
2976
|
const weighted = weightedTokens != null && Number.isFinite(Number(weightedTokens)) ? Number(weightedTokens) : usageBudgetTokensLocal(u, cacheReadWeight);
|
|
2874
|
-
const
|
|
2875
|
-
const
|
|
2977
|
+
const reqN = Math.max(1, Number(requirementCount) || 1);
|
|
2978
|
+
const perReq = weighted / reqN;
|
|
2979
|
+
const baseline = tokenBaselinePerRequirementLocal(history);
|
|
2980
|
+
let percent, basis, savedTokens;
|
|
2981
|
+
if (baseline != null) {
|
|
2982
|
+
percent = Math.max(-100, Math.min(100, Math.round(((baseline - perReq) / baseline) * 100)));
|
|
2983
|
+
basis = 'baseline';
|
|
2984
|
+
savedTokens = Math.max(0, Math.round((baseline - perReq) * reqN));
|
|
2985
|
+
} else {
|
|
2986
|
+
savedTokens = Math.max(0, raw - weighted);
|
|
2987
|
+
percent = Math.round((savedTokens / raw) * 100);
|
|
2988
|
+
basis = 'cache-weight';
|
|
2989
|
+
}
|
|
2876
2990
|
const techniques = [];
|
|
2877
|
-
|
|
2878
|
-
if (
|
|
2879
|
-
if (usedHandoff) techniques.push('
|
|
2880
|
-
if (usedCompact) techniques.push('/compact');
|
|
2881
|
-
if (usedClear) techniques.push('/clear');
|
|
2882
|
-
if (agentModel) techniques.push(
|
|
2883
|
-
if (
|
|
2884
|
-
|
|
2991
|
+
const cacheRatio = Math.round(((u.cache_read_input_tokens ?? 0) / raw) * 100);
|
|
2992
|
+
if (cacheRatio > 0) techniques.push(`${cacheRatio}% of tokens served from cache`);
|
|
2993
|
+
if (usedHandoff || sessionMode === 'cold-handoff') techniques.push('fresh handoff instead of a bloated session');
|
|
2994
|
+
if (usedCompact || sessionMode === 'compact') techniques.push('/compact before continuing');
|
|
2995
|
+
if (usedClear) techniques.push('/clear between unrelated goals');
|
|
2996
|
+
if (agentModel) techniques.push(`${agentModel} for this run`);
|
|
2997
|
+
if (basis === 'baseline' && percent > 0) techniques.push(`${Math.round(perReq / 1000)}k tokens/requirement vs your ${Math.round(baseline / 1000)}k average`);
|
|
2998
|
+
if (!techniques.length) techniques.push(`${Math.round(raw / 1000)}k tokens this run (no cache reuse yet)`);
|
|
2999
|
+
return { rawTokens: raw, weightedTokens: weighted, savedTokens, percent, basis, techniques: [...new Set(techniques)] };
|
|
2885
3000
|
}
|
|
2886
3001
|
function tokenOptimizationHtml(opt){
|
|
2887
3002
|
if (!opt) return '';
|
|
2888
|
-
const techniques = opt.techniques?.length ? opt.techniques.slice(0, 4).join(' / ') : '
|
|
3003
|
+
const techniques = opt.techniques?.length ? opt.techniques.slice(0, 4).join(' / ') : 'this run';
|
|
2889
3004
|
const pct = Number(opt.percent);
|
|
3005
|
+
const cmp = opt.basis === 'baseline' ? 'vs your recent average' : 'from prompt caching';
|
|
2890
3006
|
const text = pct > 0
|
|
2891
|
-
? `Token optimized: saved about ${pct}%
|
|
3007
|
+
? `Token optimized: saved about ${pct}% ${cmp} (${techniques})`
|
|
2892
3008
|
: `Token optimized: ${techniques}`;
|
|
2893
3009
|
return `<div class="rvtokenopt">${esc(text)}</div>`;
|
|
2894
3010
|
}
|
|
@@ -3511,6 +3627,7 @@ function renderTasks(){
|
|
|
3511
3627
|
<div class="cond">shelved — press Start to queue it</div></div>
|
|
3512
3628
|
<button type="button" class="prmini pstart" data-pstart="${g.id}">Start</button>
|
|
3513
3629
|
<button type="button" class="pdel" data-pdel="${g.id}" title="Delete">✕</button></div>`).join('');
|
|
3630
|
+
const savedQAns = captureFocusedAnswerInput($('tasklist'), 'data-qansinput');
|
|
3514
3631
|
$('tasklist').innerHTML =
|
|
3515
3632
|
sec('Attention', attention, 'attn')
|
|
3516
3633
|
+ sec(pickColumnLabel(cols, 'review', 'Review'), reviewDigest + review, 'reviewsec', reviewDefinitionFor(state.active).description, REVIEW_DEF_HINT)
|
|
@@ -3518,6 +3635,7 @@ function renderTasks(){
|
|
|
3518
3635
|
+ sec(pickColumnLabel(cols, 'todo', 'To do'), todo, 'todosec')
|
|
3519
3636
|
+ sec('Pending', pendingRows, 'dim')
|
|
3520
3637
|
+ sec(pickColumnLabel(cols, 'done', 'Done'), doneRows, 'dim');
|
|
3638
|
+
restoreFocusedAnswerInput($('tasklist'), 'data-qansinput', savedQAns);
|
|
3521
3639
|
// faint pencil beside the Review subtitle → open the review-definition editor
|
|
3522
3640
|
$('tasklist').querySelector('[data-editreviewdef]')?.addEventListener('click', (e) => { e.stopPropagation(); openWfEditor(); });
|
|
3523
3641
|
for (const el of $('tasklist').querySelectorAll('.task[data-task]')) {
|
|
@@ -3674,7 +3792,7 @@ function renderTasks(){
|
|
|
3674
3792
|
// event listeners, so all wiring above still works. Anchor comments mark each
|
|
3675
3793
|
// node's home so other layouts restore it exactly.
|
|
3676
3794
|
function ensureLayoutAnchors(){
|
|
3677
|
-
for (const id of ['tasklist', 'composerWrap', 'actpanel']) {
|
|
3795
|
+
for (const id of ['tasklist', 'composerWrap', 'actpanel', 'errbar']) {
|
|
3678
3796
|
const el = $(id);
|
|
3679
3797
|
if (el && !el._anchor) { const a = document.createComment('home:' + id); el.parentNode.insertBefore(a, el); el._anchor = a; }
|
|
3680
3798
|
}
|
|
@@ -3694,11 +3812,19 @@ function positionLayout(){
|
|
|
3694
3812
|
// is never detached mid-typing on SSE-driven renders.
|
|
3695
3813
|
const host = $('fsCompwrap'), cw = $('composerWrap');
|
|
3696
3814
|
if (host && cw && cw.parentNode !== host) host.appendChild(cw);
|
|
3815
|
+
// #errbar (showErr/showBillingBlocked/showSignInNudge) lives inside .main,
|
|
3816
|
+
// which flagship hides outright (display:none) — every transient message
|
|
3817
|
+
// silently never rendered. Adopt it beside the composer, same technique as
|
|
3818
|
+
// #composerWrap above; a flagship CSS rule floats it just above the input
|
|
3819
|
+
// (CDO判定 2026-07-16 — same fix as #gearPop's body[data-layout] escape).
|
|
3820
|
+
const eb = $('errbar');
|
|
3821
|
+
if (host && eb && eb.parentNode !== host) host.appendChild(eb);
|
|
3697
3822
|
// tasklist / actpanel go back home (they live hidden inside .main here)
|
|
3698
3823
|
for (const id of ['tasklist', 'actpanel']) { const el = $(id); if (el && el._anchor && el.parentNode !== el._anchor.parentNode) el._anchor.parentNode.insertBefore(el, el._anchor); }
|
|
3699
3824
|
return;
|
|
3700
3825
|
}
|
|
3701
3826
|
if (state.layout === 'bflow') {
|
|
3827
|
+
{ const el = $('errbar'); if (el && el._anchor && el.parentNode !== el._anchor.parentNode) el._anchor.parentNode.insertBefore(el, el._anchor); }
|
|
3702
3828
|
const bc = $('bcenter'), bt = $('brightTop'), bb = $('brightBottom');
|
|
3703
3829
|
if (!bc) return;
|
|
3704
3830
|
const tl = $('tasklist'), cw = $('composerWrap'), ap = $('actpanel');
|
|
@@ -3718,7 +3844,7 @@ function positionLayout(){
|
|
|
3718
3844
|
if (!bb.querySelector('.brbot-head')) { const h = document.createElement('div'); h.className = 'brbot-head'; h.textContent = 'Implementing'; bb.appendChild(h); }
|
|
3719
3845
|
if (ap.parentNode !== bb) bb.appendChild(ap); // right-bottom: Log → Implementing
|
|
3720
3846
|
} else {
|
|
3721
|
-
for (const id of ['tasklist', 'composerWrap', 'actpanel']) { const el = $(id); if (el && el._anchor && el.parentNode !== el._anchor.parentNode) el._anchor.parentNode.insertBefore(el, el._anchor); }
|
|
3847
|
+
for (const id of ['tasklist', 'composerWrap', 'actpanel', 'errbar']) { const el = $(id); if (el && el._anchor && el.parentNode !== el._anchor.parentNode) el._anchor.parentNode.insertBefore(el, el._anchor); }
|
|
3722
3848
|
}
|
|
3723
3849
|
}
|
|
3724
3850
|
|
|
@@ -4877,12 +5003,15 @@ function renderFsFeed(){
|
|
|
4877
5003
|
// dogfood PRs (質問カードの自由記述回答欄 / 「Needs your answer」カードを削除できるように
|
|
4878
5004
|
// — see docs/confirmed-behaviors.md): the chips-only card left no way to answer a question
|
|
4879
5005
|
// with no matching preset option, or to discard a question you don't want to answer.
|
|
5006
|
+
// Questions render at the FEED BOTTOM (composer-adjacent) — built into askHtml
|
|
5007
|
+
// so they sit AFTER the conversation turns (H18/H19 / Pattern A: 質問は末尾固定).
|
|
5008
|
+
let askHtml = '';
|
|
4880
5009
|
for (const g of state.goals.filter((g) => g.projectId === state.active && g.status === 'needsInput' && g.question)) {
|
|
4881
5010
|
const chips = (g.question.options || []).map((o) => `<button class="achip" data-fsanswer="${g.id}" data-a="${esc(o)}">${esc(o)}</button>`).join('');
|
|
4882
5011
|
// QUESTION card (H18): mono label + prompt + option chips + always-on free-text
|
|
4883
5012
|
// answer + hover trash. Data hooks (data-fsanswer / -ansinput / -anssend /
|
|
4884
5013
|
// -answerdel) are unchanged so the existing wiring below keeps working.
|
|
4885
|
-
|
|
5014
|
+
askHtml += `<div class="cc-ask"><div class="cc-ask-lb">Question</div>`
|
|
4886
5015
|
+ `<div class="cc-ask-q">${esc(g.question.text)}</div>`
|
|
4887
5016
|
+ (chips ? `<div class="qchips">${chips}</div>` : '')
|
|
4888
5017
|
+ `<div class="answ-inline"><input type="text" data-fsansinput="${g.id}" placeholder="Type your answer…" aria-label="Type your answer">`
|
|
@@ -4906,17 +5035,44 @@ function renderFsFeed(){
|
|
|
4906
5035
|
// longer flash in and wipe what the user was looking at. While work exists but
|
|
4907
5036
|
// this pass produced no lines, keep the last known feed (or a calm one-line
|
|
4908
5037
|
// status) instead of blanking to the hero.
|
|
5038
|
+
// Pattern A (H23): the conversation turns — the user's own messages, Claude
|
|
5039
|
+
// Code's replies (typed out with a caret on the streaming frontier), and
|
|
5040
|
+
// "→ Added to To Do #NN" markers — rendered from state.chatTurns so the
|
|
5041
|
+
// whole-feed re-render (SSE act events) can't drop them. Order per turn:
|
|
5042
|
+
// You → (mark, if it became a task) and, for a reply, speaker line → reply.
|
|
5043
|
+
const turns = (state.chatTurns[state.active] || []).slice(-40);
|
|
5044
|
+
let chatHtml = '';
|
|
5045
|
+
for (const turn of turns) {
|
|
5046
|
+
if (turn.role === 'you') {
|
|
5047
|
+
chatHtml += `<div class="cc-you"><span class="cy-n">You</span><div class="cy-t">${esc(turn.text)}</div></div>`;
|
|
5048
|
+
if (turn.markGoalId != null && !turn.undone) {
|
|
5049
|
+
const mg = state.goals.find((x) => x.id === turn.markGoalId);
|
|
5050
|
+
const n = mg ? goalReviewNumber({ goal: mg, tasks: state.tasks }) : turn.markGoalId;
|
|
5051
|
+
chatHtml += `<div class="cc-mark"><span class="cm-ar">→</span><span class="cm-t">Added to To Do</span><span class="cm-no">#${n}</span><button class="cm-undo" type="button" data-ccundo="${turn.markGoalId}">Undo</button></div>`;
|
|
5052
|
+
}
|
|
5053
|
+
} else { // Claude Code reply — speaker line first (You → speaker → reply), then the text
|
|
5054
|
+
const app = connectedAppMeta(turn.agent);
|
|
5055
|
+
chatHtml += `<div class="cc-who">${app.icon}<span class="cs-n">${esc(app.label)}</span></div>`;
|
|
5056
|
+
const shown = esc((turn.text || '').slice(0, turn.revealed || 0));
|
|
5057
|
+
chatHtml += turn.done
|
|
5058
|
+
? `<div class="cc-say">${shown}</div>`
|
|
5059
|
+
: `<div class="cc-say"><span id="ccTyping">${shown}</span><span class="cc-caret"></span></div>`;
|
|
5060
|
+
}
|
|
5061
|
+
}
|
|
4909
5062
|
const pgoals = state.goals.filter((g) => g.projectId === state.active);
|
|
4910
5063
|
const ptasks = state.tasks.filter((t) => t.projectId === state.active);
|
|
4911
|
-
const hasWork = pgoals.length > 0 || ptasks.length > 0;
|
|
4912
|
-
|
|
5064
|
+
const hasWork = pgoals.length > 0 || ptasks.length > 0 || turns.length > 0;
|
|
5065
|
+
// task feed (with its own speaker line) → conversation turns → question (bottom)
|
|
5066
|
+
let out = html + chatHtml + askHtml;
|
|
4913
5067
|
if (out) _fsLastFeed[state.active] = out;
|
|
4914
5068
|
else if (hasWork) out = _fsLastFeed[state.active] || `<div class="cc-c">⎿ ${esc(running.length ? 'working…' : 'no active task')}</div>`;
|
|
4915
5069
|
$('fsRoot').classList.toggle('clawdhero', !hasWork);
|
|
4916
5070
|
const sc = feed.parentNode;
|
|
4917
5071
|
const nearBottom = sc.scrollHeight - sc.clientHeight - sc.scrollTop <= 80;
|
|
5072
|
+
const savedFsAns = captureFocusedAnswerInput(feed, 'data-fsansinput');
|
|
4918
5073
|
feed.innerHTML = hasWork ? out : '';
|
|
4919
5074
|
if (nearBottom) sc.scrollTop = sc.scrollHeight;
|
|
5075
|
+
restoreFocusedAnswerInput(feed, 'data-fsansinput', savedFsAns);
|
|
4920
5076
|
window.__fsCineSync?.(); // Cinema dynamic rule: content growth may reach the band region
|
|
4921
5077
|
for (const b of feed.querySelectorAll('[data-fsretry]')) b.onclick = () => fsRetryTask(b.dataset.fsretry);
|
|
4922
5078
|
for (const b of feed.querySelectorAll('[data-fslog]')) b.onclick = () => fsToggleLog(Number(b.dataset.fslog));
|
|
@@ -4934,6 +5090,19 @@ function renderFsFeed(){
|
|
|
4934
5090
|
// Delete a "needs your answer" card outright (restores 「Needs your answer」カードを削除
|
|
4935
5091
|
// できるように — a question you don't want to answer had no discard path in flagship).
|
|
4936
5092
|
for (const b of feed.querySelectorAll('[data-fsanswerdel]')) b.onclick = (e) => { e.stopPropagation(); fsTaskDelete(Number(b.dataset.fsanswerdel)); };
|
|
5093
|
+
// Pattern A (H23): Undo a "→ Added to To Do" marker — cancel the created goal
|
|
5094
|
+
// and drop the mark. A just-created (planning) goal is deletable; if it already
|
|
5095
|
+
// started the server returns non-OK (canDeleteGoal) and we say so quietly.
|
|
5096
|
+
for (const b of feed.querySelectorAll('[data-ccundo]')) b.onclick = async (e) => {
|
|
5097
|
+
e.stopPropagation();
|
|
5098
|
+
const gid = Number(b.dataset.ccundo);
|
|
5099
|
+
const r = await fetch(withKey(`/api/goals/${gid}`), { method: 'DELETE' });
|
|
5100
|
+
if (!r.ok) { showErr('Could not undo — the task may already be running'); return; }
|
|
5101
|
+
const turn = (state.chatTurns[state.active] || []).find((t) => t.markGoalId === gid);
|
|
5102
|
+
if (turn) turn.undone = true;
|
|
5103
|
+
state.goals = state.goals.filter((g) => g.id !== gid);
|
|
5104
|
+
render();
|
|
5105
|
+
};
|
|
4937
5106
|
}
|
|
4938
5107
|
async function fsAnswerQuestion(goalId, answer){
|
|
4939
5108
|
if (!answer) return;
|
|
@@ -5456,11 +5625,10 @@ function renderClawdHero(){
|
|
|
5456
5625
|
});
|
|
5457
5626
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeMobilePanels(); });
|
|
5458
5627
|
// v46 (HANDOFF-v45 §3): the menu entries deep-link into the 4-tab settings panel —
|
|
5459
|
-
//
|
|
5628
|
+
// Theme→THEME / Connect MCP→MCP / History→HISTORY / Settings→SETTINGS.
|
|
5460
5629
|
// stopPropagation: the document click-away handler otherwise closes it in the same event.
|
|
5461
5630
|
$('fsCust').onclick = (e) => { e.stopPropagation(); $('fsMoreMenu').classList.remove('open'); custOpen('theme'); };
|
|
5462
5631
|
$('fsConn').onclick = (e) => { e.stopPropagation(); $('fsMoreMenu').classList.remove('open'); custOpen('mcp'); };
|
|
5463
|
-
$('fsTodoBd').onclick = () => { $('fsMoreMenu').classList.remove('open'); $('toggle').click(); }; // To Do board stays reachable from the menu; ⌘K itself now opens the project switcher (HANDOFF-v45 §11)
|
|
5464
5632
|
$('fsHist').onclick = (e) => { e.stopPropagation(); $('fsMoreMenu').classList.remove('open'); custOpen('history'); };
|
|
5465
5633
|
$('fsSet').onclick = (e) => { e.stopPropagation(); $('fsMoreMenu').classList.remove('open'); custOpen('settings'); };
|
|
5466
5634
|
// parallel-runs switcher (§1.5-1/-7): same toggle+click-away shape as fsMoreMenu above.
|
|
@@ -5567,12 +5735,13 @@ function fsGearAlignX(){
|
|
|
5567
5735
|
function upsert(arr, item){ const i = arr.findIndex((x) => x.id === item.id); if (i >= 0) arr[i] = item; else arr.push(item); }
|
|
5568
5736
|
|
|
5569
5737
|
let errTimer = null;
|
|
5738
|
+
let authDown = false; // true while the worker-auth banner is up → keep auto-hide timers from clearing it
|
|
5570
5739
|
function showErr(msg){
|
|
5571
5740
|
const bar = $('errbar');
|
|
5572
5741
|
bar.classList.remove('billing'); // shed the paywall styling if it was last used for that
|
|
5573
5742
|
bar.textContent = msg; bar.style.display = 'block';
|
|
5574
5743
|
clearTimeout(errTimer);
|
|
5575
|
-
errTimer = setTimeout(() => { bar.style.display = 'none'; }, 6000);
|
|
5744
|
+
errTimer = setTimeout(() => { if (authDown) return; bar.style.display = 'none'; }, 6000);
|
|
5576
5745
|
}
|
|
5577
5746
|
|
|
5578
5747
|
// Minimal placeholder for the free-tier paywall pending CDO design
|
|
@@ -5599,6 +5768,55 @@ function showBillingBlocked(blocked){
|
|
|
5599
5768
|
const btn = bar.querySelector('#bxUpgrade');
|
|
5600
5769
|
if (btn) btn.onclick = () => startCheckout();
|
|
5601
5770
|
clearTimeout(errTimer);
|
|
5771
|
+
errTimer = setTimeout(() => { if (authDown) return; bar.style.display = 'none'; bar.classList.remove('billing'); }, 20000);
|
|
5772
|
+
}
|
|
5773
|
+
|
|
5774
|
+
// Worker sign-in banner. The server preflights `claude` at boot (/api/state.auth
|
|
5775
|
+
// + SSE ev:'auth'); if it isn't signed in, every worker would fail with "Not
|
|
5776
|
+
// logged in", so the queue is held server-side and this standing banner tells
|
|
5777
|
+
// the user how to fix it. Reuses #errbar + the .bx-ask card (bordered, red as a
|
|
5778
|
+
// left rule only — no new chrome), but WITHOUT an auto-hide timer since the
|
|
5779
|
+
// condition persists until the user relaunches signed-in. Chrome is English to
|
|
5780
|
+
// match the rest of the app.
|
|
5781
|
+
const AUTH_REASON_TEXT = {
|
|
5782
|
+
'logged-out': 'Not signed in to claude. Run `claude` in a terminal and /login, then relaunch the manager from that terminal — tasks are paused until then.',
|
|
5783
|
+
'bad-credentials': 'Worker sign-in failed. Check ANTHROPIC_API_KEY, or run `claude` and /login, then relaunch the manager from that terminal.',
|
|
5784
|
+
'probe-failed': 'Couldn’t verify claude sign-in. Ensure the `claude` CLI is installed and signed in, then relaunch the manager.',
|
|
5785
|
+
};
|
|
5786
|
+
function renderAuthBanner(auth){
|
|
5787
|
+
const bar = $('errbar');
|
|
5788
|
+
if (auth && auth.ok === false){
|
|
5789
|
+
authDown = true;
|
|
5790
|
+
const text = AUTH_REASON_TEXT[auth.reason] || AUTH_REASON_TEXT['logged-out'];
|
|
5791
|
+
bar.classList.add('billing'); // strips #errbar's error fill so the card stands alone
|
|
5792
|
+
bar.innerHTML = `<div class="bx-ask"><div class="bx-qt"><div class="bx-lb">Sign in</div>`
|
|
5793
|
+
+ `<div class="bx-q">${esc(text)}</div></div></div>`;
|
|
5794
|
+
bar.style.display = 'block';
|
|
5795
|
+
clearTimeout(errTimer); // this banner persists — no auto-hide
|
|
5796
|
+
} else if (authDown) {
|
|
5797
|
+
authDown = false;
|
|
5798
|
+
bar.classList.remove('billing');
|
|
5799
|
+
bar.style.display = 'none';
|
|
5800
|
+
bar.innerHTML = '';
|
|
5801
|
+
}
|
|
5802
|
+
}
|
|
5803
|
+
|
|
5804
|
+
// Post-logout nudge (Masa指示 2026-07-16): shown once, the next time a signed-out
|
|
5805
|
+
// send() actually lands, so it reads as "you just logged out" rather than nagging
|
|
5806
|
+
// every anonymous user (the free tier needs no account — this never blocks send).
|
|
5807
|
+
// Same #errbar "ask card" look as showBillingBlocked, Sign in instead of Upgrade.
|
|
5808
|
+
// .bx-ask.info (blue, not red — CDO判定 2026-07-16): this isn't a limit/error,
|
|
5809
|
+
// red is reserved for those (§5.6).
|
|
5810
|
+
function showSignInNudge(){
|
|
5811
|
+
const bar = $('errbar');
|
|
5812
|
+
bar.classList.add('billing');
|
|
5813
|
+
bar.innerHTML = `<div class="bx-ask info"><div class="bx-qt"><div class="bx-lb">Signed out</div>`
|
|
5814
|
+
+ `<div class="bx-q">Sign in with Google to keep your usage synced across devices.</div></div>`
|
|
5815
|
+
+ `<div class="bx-act"><button class="bx-cta" id="bxSignin">Sign in</button></div></div>`;
|
|
5816
|
+
bar.style.display = 'block';
|
|
5817
|
+
const btn = bar.querySelector('#bxSignin');
|
|
5818
|
+
if (btn) btn.onclick = () => beginGoogleSignin((err) => showErr(err));
|
|
5819
|
+
clearTimeout(errTimer);
|
|
5602
5820
|
errTimer = setTimeout(() => { bar.style.display = 'none'; bar.classList.remove('billing'); }, 20000);
|
|
5603
5821
|
}
|
|
5604
5822
|
|
|
@@ -5670,6 +5888,21 @@ function startGoogleSignin(url){
|
|
|
5670
5888
|
const w = window.open(url, 'galda-signin', 'width=520,height=680');
|
|
5671
5889
|
if (!w) location.href = url; // popup blocked → same-tab fallback
|
|
5672
5890
|
}
|
|
5891
|
+
// Shared by the Settings "Sign in with Google" button and the post-logout nudge
|
|
5892
|
+
// (showSignInNudge below) so both go through the same signin-url → popup path.
|
|
5893
|
+
async function beginGoogleSignin(onError){
|
|
5894
|
+
try {
|
|
5895
|
+
const r = await fetch(withKey('/api/signin-url'));
|
|
5896
|
+
const data = await r.json().catch(() => ({}));
|
|
5897
|
+
if (r.ok && data.url) { startGoogleSignin(data.url); return true; }
|
|
5898
|
+
onError?.(data.error || 'Sign-in is not available.');
|
|
5899
|
+
} catch { onError?.('Sign-in failed — check your connection.'); }
|
|
5900
|
+
return false;
|
|
5901
|
+
}
|
|
5902
|
+
// Set right after a confirmed Log out; consumed once by the next successful
|
|
5903
|
+
// send() while still signed out, to nudge the user back toward signing in
|
|
5904
|
+
// (Masa指示 2026-07-16) — never blocks the send itself (free tier needs no account).
|
|
5905
|
+
let recentlySignedOut = false;
|
|
5673
5906
|
async function refreshAfterSignin(){
|
|
5674
5907
|
await refresh();
|
|
5675
5908
|
if (state.billing?.licensed) signinPending = false;
|
|
@@ -5713,6 +5946,8 @@ async function refresh(){
|
|
|
5713
5946
|
for (const t of state.tasks) { if (t.todos?.length) state.todos[t.id] = t.todos; }
|
|
5714
5947
|
state.connectCommand = s.connectCommand ?? ''; state.models = s.models ?? ['sonnet']; state.agentModels = s.agentModels ?? null; state.agentEfforts = s.agentEfforts ?? null; state.agents = s.agents ?? ['claude-code', 'codex']; state.uiVersion = s.uiVersion;
|
|
5715
5948
|
state.billing = s.billing ?? { licensed: false, email: null };
|
|
5949
|
+
renderAppSelect(); // picker follows the server's installed-agent set; may correct a stale connectedApp
|
|
5950
|
+
renderAuthBanner(s.auth); // show/clear the worker sign-in banner from the boot preflight
|
|
5716
5951
|
setAgentModel(state.connectedApp);
|
|
5717
5952
|
setAgentEffort(state.connectedApp);
|
|
5718
5953
|
renderModelSelect();
|
|
@@ -5769,6 +6004,7 @@ function connectSSE(){
|
|
|
5769
6004
|
if (!state.projects.some((p) => p.id === state.active)) state.active = state.projects[0]?.id;
|
|
5770
6005
|
}
|
|
5771
6006
|
else if (m.ev === 'ping') { checkUiVersion(m.uiVersion); return; }
|
|
6007
|
+
else if (m.ev === 'auth') { renderAuthBanner(m.auth); return; } // worker sign-in preflight result
|
|
5772
6008
|
scheduleRender();
|
|
5773
6009
|
};
|
|
5774
6010
|
es.onerror = () => {
|
|
@@ -5816,10 +6052,13 @@ function bootDisconnected(info){
|
|
|
5816
6052
|
#connectGate .cg-cmd button{font:500 11px/1 var(--mono,monospace);color:#a3a097;background:transparent;
|
|
5817
6053
|
border:1px solid rgba(255,255,255,.18);border-radius:7px;padding:6px 9px;cursor:pointer}
|
|
5818
6054
|
#connectGate .cg-cmd button:hover{color:#fff}
|
|
6055
|
+
#connectGate .cg-hint{font-size:12px;line-height:1.6;color:var(--ink3,#6b6862);margin:0 0 16px}
|
|
6056
|
+
#connectGate .cg-hint a{color:var(--blue,#8fb0ff);text-decoration:none}
|
|
6057
|
+
#connectGate .cg-hint a:hover{text-decoration:underline}
|
|
5819
6058
|
#connectGate .cg-who{font-size:12px;color:var(--ink3,#6b6862);margin:0 0 4px}
|
|
5820
6059
|
#connectGate .cg-who b{color:var(--ink2,#a3a097)}
|
|
5821
6060
|
#connectGate .cg-switch{font-size:12px;color:var(--ink3,#6b6862);margin:14px 0 0}
|
|
5822
|
-
#connectGate .cg-switch a{color
|
|
6061
|
+
#connectGate .cg-switch a{color:var(--blue,#8fb0ff);text-decoration:none}
|
|
5823
6062
|
#connectGate .cg-x{position:absolute;top:14px;right:16px;width:28px;height:28px;border:0;background:transparent;color:var(--ink3,#6b6862);font-size:20px;line-height:1;cursor:pointer}
|
|
5824
6063
|
#connectGate .cg-wait{display:inline-flex;align-items:center;gap:7px;font:500 10.5px/1 var(--mono,monospace);letter-spacing:.08em;color:var(--ink3,#6b6862);margin-top:16px}
|
|
5825
6064
|
#connectGate .cg-wait i{width:6px;height:6px;border-radius:50%;background:var(--green,#3DDC97);animation:cgpulse 1.8s ease-in-out infinite}
|
|
@@ -5833,6 +6072,13 @@ function bootDisconnected(info){
|
|
|
5833
6072
|
+ '<h2>Connect this device to start</h2>'
|
|
5834
6073
|
+ '<p>Galda runs on your own machine. Run this where you use Claude Code or Codex — this board comes alive the moment it connects, no refresh needed:</p>'
|
|
5835
6074
|
+ '<div class="cg-cmd"><code>npx @galda/cli</code><button class="cg-copy">Copy</button></div>'
|
|
6075
|
+
// One command connects the machine, not an agent: the CLI probes PATH and hands
|
|
6076
|
+
// the whole detected set to the server. So we never split this per agent — we
|
|
6077
|
+
// only point people without either one at the official installers.
|
|
6078
|
+
+ '<p class="cg-hint">Don\'t have either yet? Install '
|
|
6079
|
+
+ '<a href="https://claude.com/claude-code" target="_blank" rel="noreferrer">Claude Code</a> or '
|
|
6080
|
+
+ '<a href="https://openai.com/codex/" target="_blank" rel="noreferrer">Codex</a>, then run the same command '
|
|
6081
|
+
+ '— Galda uses whichever agent you have.</p>'
|
|
5836
6082
|
+ '<p class="cg-who">Signed in as <b></b></p>'
|
|
5837
6083
|
+ '<p class="cg-switch">Already running Galda under a different account? Run <code>npx @galda/cli --signin</code> and pick this one. Or <a href="/logout">switch this browser →</a></p>'
|
|
5838
6084
|
+ '<div class="cg-wait"><i></i>WAITING FOR THIS DEVICE</div></div>';
|
|
@@ -5944,21 +6190,37 @@ async function postOutboxItem(item){
|
|
|
5944
6190
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
5945
6191
|
const goal = await r.json();
|
|
5946
6192
|
if (goal.kind === 'chat') {
|
|
5947
|
-
// Conversation-first
|
|
5948
|
-
//
|
|
5949
|
-
//
|
|
5950
|
-
//
|
|
5951
|
-
//
|
|
5952
|
-
//
|
|
5953
|
-
// "Sending…" row in the queue forever and pollute state.goals.
|
|
6193
|
+
// Conversation-first (SLICE 2 + Pattern A H23): a chat/help/settings turn,
|
|
6194
|
+
// not a To-Do. Retire the optimistic outbox item — a chat turn is ephemeral
|
|
6195
|
+
// by design, so never-lose (which protects real tasks/goals) is untouched.
|
|
6196
|
+
// In flagship the reply is a turn in the center feed (typed out with a
|
|
6197
|
+
// caret); the board layout has no feed, so it falls back to the quiet
|
|
6198
|
+
// .peraanswer bar. Never land()/upsert(): a kind:'chat' response has no id.
|
|
5954
6199
|
outbox.remove(item.oid);
|
|
5955
|
-
|
|
6200
|
+
if (state.layout === 'flagship') {
|
|
6201
|
+
const turn = { role: 'clawd', text: String(goal.reply || ''), revealed: 0, done: false, agent: item.agent };
|
|
6202
|
+
(state.chatTurns[item.projectId] ||= []).push(turn);
|
|
6203
|
+
renderFsFeed();
|
|
6204
|
+
typeChatTurn(turn);
|
|
6205
|
+
} else {
|
|
6206
|
+
showChatReply(goal.reply);
|
|
6207
|
+
}
|
|
5956
6208
|
render();
|
|
5957
6209
|
return; // finally still runs posting.delete(); skip land()/upsert()
|
|
5958
6210
|
}
|
|
5959
6211
|
goal.imageUrls = item.imageUrls;
|
|
5960
6212
|
outbox.land(item.oid, goal.id);
|
|
5961
6213
|
upsert(state.goals, goal);
|
|
6214
|
+
if (recentlySignedOut && !state.billing?.licensed) { recentlySignedOut = false; showSignInNudge(); }
|
|
6215
|
+
// Pattern A (H23): this send became a real To-Do — mark the most recent
|
|
6216
|
+
// unmarked user turn "→ Added to To Do #NN" in the feed. Skip a needsInput
|
|
6217
|
+
// goal (it's still asking; shown as the question card at the feed bottom).
|
|
6218
|
+
if (goal.status !== 'needsInput') {
|
|
6219
|
+
const turns = state.chatTurns[item.projectId] || [];
|
|
6220
|
+
for (let k = turns.length - 1; k >= 0; k--) {
|
|
6221
|
+
if (turns[k].role === 'you' && turns[k].markGoalId == null) { turns[k].markGoalId = goal.id; break; }
|
|
6222
|
+
}
|
|
6223
|
+
}
|
|
5962
6224
|
} catch {
|
|
5963
6225
|
outbox.mark(item.oid, 'unsent');
|
|
5964
6226
|
} finally {
|
|
@@ -5967,16 +6229,57 @@ async function postOutboxItem(item){
|
|
|
5967
6229
|
render();
|
|
5968
6230
|
}
|
|
5969
6231
|
|
|
5970
|
-
// SLICE 2
|
|
5971
|
-
//
|
|
5972
|
-
//
|
|
6232
|
+
// SLICE 2 / Pattern A (H23): the conversational reply TYPES out — "read along as
|
|
6233
|
+
// it types" (ChatGPT / Claude Code). The claude CLI returns the reply as one
|
|
6234
|
+
// block, NOT token deltas (verified), so this is a client-side reveal of the full
|
|
6235
|
+
// text, not network streaming. Reveal progress lives on the turn MODEL
|
|
6236
|
+
// (turn.revealed), so the whole-feed re-render (SSE `act` events rebuild #fsFeed)
|
|
6237
|
+
// can't reset it; between re-renders we patch just the #ccTyping text node so we
|
|
6238
|
+
// don't rebuild the feed 60×/sec. Duration-normalized (~2s) so short/long both
|
|
6239
|
+
// feel right.
|
|
6240
|
+
let _chatTyper = null;
|
|
6241
|
+
function stopChatTyper(){ if (_chatTyper) { clearInterval(_chatTyper); _chatTyper = null; } }
|
|
6242
|
+
// Flagship: reveal a reply turn in the center feed. The caret sits next to
|
|
6243
|
+
// #ccTyping (a separate span) so patching the text node never wipes it.
|
|
6244
|
+
function typeChatTurn(turn){
|
|
6245
|
+
stopChatTyper();
|
|
6246
|
+
const full = turn.text || '';
|
|
6247
|
+
if (!full) { turn.done = true; return; }
|
|
6248
|
+
const step = Math.max(1, Math.round(full.length / 120)); // ~120 frames ≈ 2s
|
|
6249
|
+
_chatTyper = setInterval(() => {
|
|
6250
|
+
turn.revealed = Math.min(full.length, turn.revealed + step);
|
|
6251
|
+
const node = $('ccTyping');
|
|
6252
|
+
if (node) node.textContent = full.slice(0, turn.revealed);
|
|
6253
|
+
if (turn.revealed >= full.length) { turn.done = true; stopChatTyper(); renderFsFeed(); }
|
|
6254
|
+
}, 16);
|
|
6255
|
+
}
|
|
6256
|
+
// A new send starts: finish any in-flight reply cleanly (complete the model turn +
|
|
6257
|
+
// drop the caret, hide the board bar) so a half-typed reply never lingers.
|
|
6258
|
+
function finalizeChatTyping(){
|
|
6259
|
+
stopChatTyper();
|
|
6260
|
+
const turns = state.chatTurns[state.active] || [];
|
|
6261
|
+
for (let k = turns.length - 1; k >= 0; k--) {
|
|
6262
|
+
if (turns[k].role === 'clawd' && !turns[k].done) { turns[k].revealed = (turns[k].text || '').length; turns[k].done = true; break; }
|
|
6263
|
+
}
|
|
6264
|
+
const el = $('chatReply'); if (el) { el.hidden = true; el.textContent = ''; }
|
|
6265
|
+
}
|
|
6266
|
+
// Board layout (no center feed) keeps the quiet .peraanswer bar; same reveal.
|
|
5973
6267
|
function showChatReply(text){
|
|
5974
6268
|
const el = $('chatReply'); if (!el) return;
|
|
5975
|
-
|
|
5976
|
-
|
|
5977
|
-
el.
|
|
5978
|
-
|
|
5979
|
-
|
|
6269
|
+
stopChatTyper();
|
|
6270
|
+
const full = (text || '').trim();
|
|
6271
|
+
if (!full) { el.hidden = true; el.textContent = ''; return; }
|
|
6272
|
+
el.className = 'peraanswer'; el.textContent = ''; el.hidden = false;
|
|
6273
|
+
let i = 0;
|
|
6274
|
+
const step = Math.max(1, Math.round(full.length / 120));
|
|
6275
|
+
_chatTyper = setInterval(() => {
|
|
6276
|
+
i = Math.min(full.length, i + step);
|
|
6277
|
+
el.textContent = full.slice(0, i);
|
|
6278
|
+
el.scrollTop = el.scrollHeight;
|
|
6279
|
+
if (i >= full.length) stopChatTyper();
|
|
6280
|
+
}, 16);
|
|
6281
|
+
}
|
|
6282
|
+
function clearChatReply(){ finalizeChatTyping(); }
|
|
5980
6283
|
|
|
5981
6284
|
async function flushOutbox(){
|
|
5982
6285
|
// Only (re)send items that still need it — never re-POST 'landed' memos that
|
|
@@ -6048,6 +6351,10 @@ async function send(){
|
|
|
6048
6351
|
status: 'sending', ts: Date.now(),
|
|
6049
6352
|
};
|
|
6050
6353
|
outbox.add(item); // saved locally BEFORE any network call
|
|
6354
|
+
// Pattern A (H23): the user's own turn enters the center feed immediately, so
|
|
6355
|
+
// the composer reads like a conversation. If this send becomes a To-Do, a
|
|
6356
|
+
// .cc-mark is attached to this turn later; if it's chat, a reply turn follows.
|
|
6357
|
+
(state.chatTurns[state.active] ||= []).push({ role: 'you', text, ts: Date.now() });
|
|
6051
6358
|
state.attach = [];
|
|
6052
6359
|
// mode/skill/later/review are per-message → clear after send (model stays sticky).
|
|
6053
6360
|
state.msg = { mode: null, model: null, effort: null, skill: null, later: false, review: false };
|
|
@@ -6244,6 +6551,8 @@ function saRows(){
|
|
|
6244
6551
|
usedCompact: g?.executionPlan?.sessionMode === 'compact',
|
|
6245
6552
|
usedClear: Boolean(g?.sessionClosedAt),
|
|
6246
6553
|
agentModel: g?.model,
|
|
6554
|
+
requirementCount: ts.filter((t) => t.goalId === g?.id && !t.reply).length,
|
|
6555
|
+
history: goalTokenHistoryLocal(gs, ts, g?.id),
|
|
6247
6556
|
}),
|
|
6248
6557
|
};
|
|
6249
6558
|
});
|
|
@@ -7555,11 +7864,11 @@ function gearSetHTML(){
|
|
|
7555
7864
|
<button class="optchip" data-gsignin="1">Sign in with Google</button></div>
|
|
7556
7865
|
<div class="srow dis" id="licKeyMsg" style="display:none"></div>`;
|
|
7557
7866
|
} else if (bill.plan === 'paid') {
|
|
7558
|
-
billingRow = `<div class="srow"><div class="sl">Account<span class="sd">Signed in with Google</span></div><
|
|
7867
|
+
billingRow = `<div class="srow"><div class="sl">Account<span class="sd">Signed in with Google · ${esc(bill.email)}</span></div><button class="optchip" data-gsignout="1">Log out</button></div>
|
|
7559
7868
|
<div class="srow"><div class="sl">Plan</div><span class="bx-pdot"><i></i>Paid</span></div>
|
|
7560
7869
|
<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>`;
|
|
7561
7870
|
} else {
|
|
7562
|
-
billingRow = `<div class="srow"><div class="sl">Account<span class="sd">Signed in with Google</span></div><
|
|
7871
|
+
billingRow = `<div class="srow"><div class="sl">Account<span class="sd">Signed in with Google · ${esc(bill.email)}</span></div><button class="optchip" data-gsignout="1">Log out</button></div>
|
|
7563
7872
|
<div class="srow"><div class="sl">Plan</div><span class="bx-sval">Free</span></div>
|
|
7564
7873
|
<div class="srow"><div class="sl">To Dos<span class="sd">pending, this install</span></div><span class="bx-sval bx-num">${u.pending ?? 0} / ${lim.pending ?? 5}</span></div>
|
|
7565
7874
|
<div class="srow"><div class="sl">Lifetime</div><span class="bx-sval bx-num">${u.cumulative ?? 0} / ${lim.cumulative ?? 19}</span></div>
|
|
@@ -7609,18 +7918,20 @@ function buildGearPop(){
|
|
|
7609
7918
|
if (signBtn) signBtn.onclick = async () => {
|
|
7610
7919
|
const msg = pop.querySelector('#licKeyMsg');
|
|
7611
7920
|
signBtn.disabled = true;
|
|
7612
|
-
|
|
7613
|
-
|
|
7614
|
-
const data = await r.json().catch(() => ({}));
|
|
7615
|
-
if (r.ok && data.url) { startGoogleSignin(data.url); }
|
|
7616
|
-
else { msg.style.display = ''; msg.textContent = data.error || 'Sign-in is not available.'; }
|
|
7617
|
-
} catch { msg.style.display = ''; msg.textContent = 'Sign-in failed — check your connection.'; }
|
|
7618
|
-
finally { signBtn.disabled = false; }
|
|
7921
|
+
await beginGoogleSignin((err) => { msg.style.display = ''; msg.textContent = err; });
|
|
7922
|
+
signBtn.disabled = false;
|
|
7619
7923
|
};
|
|
7620
7924
|
const upBtn = pop.querySelector('[data-gupgrade]');
|
|
7621
7925
|
if (upBtn) upBtn.onclick = () => startCheckout();
|
|
7622
7926
|
const portalBtn = pop.querySelector('[data-gportal]');
|
|
7623
7927
|
if (portalBtn) portalBtn.onclick = () => openBillingPortal();
|
|
7928
|
+
const signOutBtn = pop.querySelector('[data-gsignout]');
|
|
7929
|
+
if (signOutBtn) signOutBtn.onclick = async () => {
|
|
7930
|
+
if (!(await confirmDelete({ title: 'Log out of Google?', body: 'You can sign back in anytime.', confirmLabel: 'Log out', confirmDanger: false }))) return;
|
|
7931
|
+
signOutBtn.disabled = true;
|
|
7932
|
+
try { await fetch(withKey('/api/signout'), { method: 'POST' }); recentlySignedOut = true; }
|
|
7933
|
+
finally { await refresh(); if ($('gearPop')) buildGearPop(); }
|
|
7934
|
+
};
|
|
7624
7935
|
return;
|
|
7625
7936
|
}
|
|
7626
7937
|
if (custTab === 'mcp') {
|
|
@@ -7804,11 +8115,14 @@ $('gearBtn').onclick = (e) => { e.stopPropagation(); const p = $('gearPop'); if
|
|
|
7804
8115
|
// outside-tap = Cancel (v45 §3). e.composedPath() is frozen at dispatch, so a swatch that
|
|
7805
8116
|
// buildGearPop() re-rendered (detached) mid-click still reads as INSIDE the pop —
|
|
7806
8117
|
// selections never close the panel. Known trap (v45 §3): #fsMoreMenu clicks are excluded
|
|
7807
|
-
// so a menu item is never swallowed as an outside tap.
|
|
8118
|
+
// so a menu item is never swallowed as an outside tap. #deleteConfirmOverlay is excluded
|
|
8119
|
+
// too (Masa指示 2026-07-16): it's a separate top-level overlay a settings row can open
|
|
8120
|
+
// (e.g. Log out) — without this, confirming/cancelling it read as an outside tap and
|
|
8121
|
+
// closed the settings panel underneath it as a surprise side effect.
|
|
7808
8122
|
document.addEventListener('click', (e) => {
|
|
7809
8123
|
const p = $('gearPop'); if (!p.classList.contains('open')) return;
|
|
7810
8124
|
if (e.composedPath().includes(p)) return;
|
|
7811
|
-
if (e.target.closest('.gearwrap') || e.target.closest('#fsCust') || e.target.closest('#fsMoreMenu') || e.target.closest('#fsMoreBtn')) return;
|
|
8125
|
+
if (e.target.closest('.gearwrap') || e.target.closest('#fsCust') || e.target.closest('#fsMoreMenu') || e.target.closest('#fsMoreBtn') || e.target.closest('#deleteConfirmOverlay')) return;
|
|
7812
8126
|
custCancel();
|
|
7813
8127
|
});
|
|
7814
8128
|
// Banff BACKDROP free upload (v3 §6.5): file → dataURL → #themebg. Persists via
|