@galda/cli 0.10.48 → 0.10.50
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 +104 -29
- package/engine/server.mjs +25 -7
- package/package.json +1 -1
package/app/index.html
CHANGED
|
@@ -472,7 +472,17 @@
|
|
|
472
472
|
scoped here, since every other .qsub stays a one-liner. */
|
|
473
473
|
.replynote{white-space:normal;overflow:visible;margin-top:14px;padding-top:12px;border-top:1px solid var(--hair)}
|
|
474
474
|
.replynote .qchips{margin-top:6px}
|
|
475
|
-
.
|
|
475
|
+
/* The .txr You/Claude rows are styled by `.redesign .txr` — which the See-all card
|
|
476
|
+
(.sa-item.redesign) provides, but the goal-detail overlay and review dialog do NOT.
|
|
477
|
+
There the same note rendered as unstyled run-in black text ("Youこんにちは" with no
|
|
478
|
+
gap/weight — Masa 2026-07-24 "弾"). Scope the row layout to .replynote too so the
|
|
479
|
+
reply thread reads the same wherever its box lives. */
|
|
480
|
+
.replynote .txr{display:flex;gap:12px;padding:5px 0;font-size:13px;line-height:1.55;align-items:baseline}
|
|
481
|
+
.replynote .txr .w{flex:0 0 46px;font-size:11px;color:var(--ink3)}
|
|
482
|
+
.replynote .txr.you .w,.replynote .txr.cl .w{color:var(--ink);font-weight:600}
|
|
483
|
+
.replynote .txr .m{flex:1;min-width:0;color:var(--ink2)}
|
|
484
|
+
.replynote .txr.cl .m{color:var(--ink)}
|
|
485
|
+
.replynote .txr.note .m{font-family:var(--mono);font-size:12px;color:var(--ink3)}
|
|
476
486
|
.ob-unsent{color:rgb(var(--danger-rgb));cursor:pointer}
|
|
477
487
|
[data-oresend]{cursor:pointer}
|
|
478
488
|
.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}
|
|
@@ -1329,13 +1339,17 @@
|
|
|
1329
1339
|
color:var(--ink2);font:inherit;font-size:12.5px;white-space:nowrap;overflow:hidden;cursor:pointer;text-align:left;width:100%;position:relative}
|
|
1330
1340
|
#fsRoot .fsproj:hover{background:var(--hover-s)}
|
|
1331
1341
|
#fsRoot .fsproj.on{background:var(--hover);color:var(--ink)}
|
|
1332
|
-
#fsRoot .fsproj i{width:4px;height:4px;border-radius:50%;background:var(--ink3);flex:0 0 4px}
|
|
1342
|
+
#fsRoot .fsproj i{width:4px;height:4px;border-radius:50%;background:var(--ink3);flex:0 0 4px;position:relative;top:1.5px} /* top:1.5px = optical nudge so the dot/running-ring centre sits on the name's x-height, not the line-box centre (Masa 2026-07-24, rail=D). Same shift for idle dot + running ring keeps the column aligned. */
|
|
1333
1343
|
#fsRoot .fsproj.on i{background:var(--green)}
|
|
1334
1344
|
/* running project = solid saturated green dot with a breathing glow — never dims to
|
|
1335
1345
|
grey, independent of selection (HANDOFF-v45 §11, flagship .proj.run port) */
|
|
1336
|
-
|
|
1346
|
+
/* running project = a spinning broken green ring (matches the Doing marker · flagship #316).
|
|
1347
|
+
Was a pulsing dot (fsrundot). Centre WITHOUT transform (spin's rotate would override a centring
|
|
1348
|
+
translate → drift bottom-right) via top/left:50% + negative-half margin; concentric with the 4px dot. */
|
|
1349
|
+
#fsRoot .fsproj.run i{background:none;box-shadow:none;position:relative}
|
|
1350
|
+
#fsRoot .fsproj.run i::after{content:"";position:absolute;top:50%;left:50%;width:7px;height:7px;margin:-3.5px 0 0 -3.5px;box-sizing:border-box;border-radius:50%;border:1.3px solid #22c55e;border-top-color:transparent;animation:spin 1.8s linear infinite}
|
|
1337
1351
|
@keyframes fsrundot{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--green) 50%,transparent)}50%{box-shadow:0 0 5px 1.5px color-mix(in srgb,var(--green) 35%,transparent)}}
|
|
1338
|
-
@media (prefers-reduced-motion:reduce){#fsRoot .fsproj.run i{animation:none}}
|
|
1352
|
+
@media (prefers-reduced-motion:reduce){#fsRoot .fsproj.run i::after{animation:none}}
|
|
1339
1353
|
#fsRoot .fsproj .pname{min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
1340
1354
|
#fsRoot .fsproj .num{margin-left:auto;font-size:10px;color:var(--ink3);flex:0 0 auto}
|
|
1341
1355
|
/* per-project counts (HANDOFF-v45 §10): To Do = rounded-square icon, Review = Scan
|
|
@@ -2276,10 +2290,13 @@
|
|
|
2276
2290
|
#fsRoot .trow .tt{color:color-mix(in srgb,var(--ink) 84%,var(--panel))} /* §13.2 soften To Do row name (not pure black); left chat feed keeps --ink */
|
|
2277
2291
|
#fsRoot .trow:hover{background:var(--hover-s)}
|
|
2278
2292
|
#fsRoot .trow.now{font-weight:500}
|
|
2279
|
-
#fsRoot .trow .st{width:13px;height:13px;border-radius:50%;border:1.5px solid var(--hair2);flex:0 0 13px}
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
#fsRoot .trow .st.run
|
|
2293
|
+
#fsRoot .trow .st{width:13px;height:13px;border-radius:50%;border:1.5px solid var(--hair2);flex:0 0 13px;position:relative;top:1px} /* top:1px = optical nudge so the whole status column (To Do/Doing/Failed/Done) centres on the row text, not the line-box (Masa 2026-07-24, board=C) */
|
|
2294
|
+
/* Doing marker = a broken green ring that spins (pattern B · flagship #316). Running only —
|
|
2295
|
+
.bad (failed) / .int (paused) stay static: no dead motion (DESIGN-RULES §5.5). Reuses @keyframes spin. */
|
|
2296
|
+
#fsRoot .trow .st.run{border-color:var(--green);border-top-color:transparent;background:none;box-shadow:0 0 6px color-mix(in srgb,var(--green) 40%,transparent);animation:spin 1.8s linear infinite;animation-delay:var(--spd,0ms)} /* --spd = wall-clock phase (set in render()) so a re-created board marker doesn't restart the spin at 0deg */
|
|
2297
|
+
#fsRoot .trow .st.run.bad{border-color:var(--danger);background:radial-gradient(circle,rgba(var(--danger-rgb),.42),transparent 70%);animation:none;box-shadow:none}
|
|
2298
|
+
#fsRoot .trow .st.run.int{border-color:var(--amber);background:radial-gradient(circle,rgba(var(--amber-rgb),.4),transparent 70%);animation:none;box-shadow:none}
|
|
2299
|
+
@media (prefers-reduced-motion:reduce){#fsRoot .trow .st.run{animation:none}}
|
|
2283
2300
|
/* Doing-failure state word (HANDOFF-v45 §11): colour TEXT only, no badge/pill surface —
|
|
2284
2301
|
Failed = red, Interrupted = amber. */
|
|
2285
2302
|
#fsRoot .trow .stword{font-size:10.5px;font-weight:600;color:var(--danger);flex:0 0 auto}
|
|
@@ -2380,7 +2397,7 @@
|
|
|
2380
2397
|
/* GOTCHA (HANDOFF-v45 §11): the rule above wins over a plain .fsproj.run i by
|
|
2381
2398
|
specificity and greys the running dot out on the Gradient wallpaper rail — this
|
|
2382
2399
|
exception restores solid green so "running" never dims to grey. */
|
|
2383
|
-
body[data-theme="gradient"] #fsRoot .fsproj.run i{background
|
|
2400
|
+
body[data-theme="gradient"] #fsRoot .fsproj.run i{background:none}
|
|
2384
2401
|
body[data-theme="gradient"] #fsRoot .fsproj .num,body[data-theme="gradient"] #fsRoot .fsproj .pcount{color:rgba(20,23,31,.6)}
|
|
2385
2402
|
body[data-theme="gradient"] #fsRoot .fsproj:not(.on):hover{background:rgba(255,255,255,.34)}
|
|
2386
2403
|
body[data-theme="gradient"] #fsRoot .fsproj.on{background:rgba(255,255,255,.5);color:var(--ink)}
|
|
@@ -2517,7 +2534,7 @@
|
|
|
2517
2534
|
body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj{color:rgba(255,255,255,.85);text-shadow:0 1px 3px rgba(0,0,0,.35)}
|
|
2518
2535
|
body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj i{background:rgba(255,255,255,.6)}
|
|
2519
2536
|
/* same GOTCHA exception as the light Gradient rail, dark variant (HANDOFF-v45 §11) */
|
|
2520
|
-
body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj.run i{background
|
|
2537
|
+
body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj.run i{background:none}
|
|
2521
2538
|
body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj .num,body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj .pcount{color:rgba(255,255,255,.6)}
|
|
2522
2539
|
body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj.on{background:rgba(255,255,255,.15);color:#fff}
|
|
2523
2540
|
body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj:not(.on):hover{background:rgba(255,255,255,.1)}
|
|
@@ -3323,7 +3340,7 @@ const savedTheme = (() => {
|
|
|
3323
3340
|
if (v !== s) localStorage.setItem('theme', v);
|
|
3324
3341
|
return v;
|
|
3325
3342
|
})();
|
|
3326
|
-
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,
|
|
3343
|
+
const state = { repos: null, repoHome: '', /* GET /api/repos — the folders a project can point at (Context Bar) */ projects: [], goals: [], tasks: [], act: {}, actAt: {}, todos: {}, chatTurns: {}, active: localStorage.getItem('sel:project') || 'default', editing: null, attach: [], connectCommand: '', models: ['sonnet'], agentModels: null, agentEfforts: null, agents: ['claude-code', 'codex'], 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,
|
|
3327
3344
|
// per-message send overrides set via slash-commands (reset after each send,
|
|
3328
3345
|
// except model which is sticky in localStorage). palette = open command list.
|
|
3329
3346
|
msg: { mode: null, model: null, effort: null, skill: null, later: false, review: false },
|
|
@@ -6142,6 +6159,13 @@ function render(){
|
|
|
6142
6159
|
if (typeof SA !== 'undefined' && SA.open) saSync(); // keep the open See all Ledger live
|
|
6143
6160
|
const bv = $('botver'); if (bv && state.uiVersion) bv.textContent = 'build ' + String(state.uiVersion).slice(0, 8);
|
|
6144
6161
|
paintReplyNote(); // a reply's answer/question survives the re-render it caused
|
|
6162
|
+
// Persist the selected project so a reload (a real UI ship, or any reconnect)
|
|
6163
|
+
// restores the user's view instead of snapping back to 'default' (Masa
|
|
6164
|
+
// 2026-07-24 "勝手にデフォルトのプロジェクトに戻される"). Only store an id that
|
|
6165
|
+
// currently exists as a project, so onboarding/transient sentinels
|
|
6166
|
+
// ('__onboard') are never saved and the restore always lands on a real one;
|
|
6167
|
+
// an id that later disappears self-heals via the guards in refresh()/'projects'.
|
|
6168
|
+
if (state.projects.some((p) => p.id === state.active)) localStorage.setItem('sel:project', state.active);
|
|
6145
6169
|
}
|
|
6146
6170
|
|
|
6147
6171
|
// ============================================================================
|
|
@@ -6181,6 +6205,11 @@ function renderFlagship(){
|
|
|
6181
6205
|
if (state.layout !== 'flagship') return;
|
|
6182
6206
|
const root = $('fsRoot'); if (!root) return;
|
|
6183
6207
|
root.dataset.rv = String(fsUI.rv);
|
|
6208
|
+
// Wall-clock phase for the spinning markers: any ring re-created this render (the board
|
|
6209
|
+
// re-renders often on progress %) starts at the angle a continuous one would be at now,
|
|
6210
|
+
// instead of snapping back to 0deg — no "jump". The rail ring is guarded separately and
|
|
6211
|
+
// doesn't read --spd, so changing it here can't disturb the persistent rail ring. (Masa 2026-07-24)
|
|
6212
|
+
root.style.setProperty('--spd', (-(Date.now() % 1800)) + 'ms');
|
|
6184
6213
|
renderFsRail(); renderFsFeed(); renderFsLane();
|
|
6185
6214
|
}
|
|
6186
6215
|
function renderFsRail(){
|
|
@@ -6188,7 +6217,7 @@ function renderFsRail(){
|
|
|
6188
6217
|
const activeProject = state.projects.find((p) => p.id === state.active);
|
|
6189
6218
|
const activeLabel = $('fsProjBtnName');
|
|
6190
6219
|
if (activeLabel) activeLabel.textContent = activeProject?.name ?? 'Projects';
|
|
6191
|
-
|
|
6220
|
+
const html = state.projects.map((p) => {
|
|
6192
6221
|
const ptasks = state.tasks.filter((t) => t.projectId === p.id);
|
|
6193
6222
|
const pgoals = state.goals.filter((g) => g.projectId === p.id);
|
|
6194
6223
|
// running dot (HANDOFF-v45 §11): a REAL running task, independent of selection —
|
|
@@ -6202,6 +6231,11 @@ function renderFsRail(){
|
|
|
6202
6231
|
return `<button class="fsproj${p.id === state.active ? ' on' : ''}${running ? ' run' : ''}" data-fsproj="${p.id}"><i></i><span class="pname">${esc(p.name)}</span>${pcount}</button>`;
|
|
6203
6232
|
}).join('')
|
|
6204
6233
|
+ `<button class="fsprojadd" id="fsProjAdd" title="New project"><span class="pa-plus"></span><span class="pa-label">New project</span></button>`;
|
|
6234
|
+
// Skip the DOM swap when the rail is unchanged: re-creating .fsproj.run would restart the
|
|
6235
|
+
// spinning ring's CSS animation — a visible "jump" on every SSE-driven re-render (Masa 2026-07-24).
|
|
6236
|
+
if (html === renderFsRail._html && el.childElementCount) return;
|
|
6237
|
+
renderFsRail._html = html;
|
|
6238
|
+
el.innerHTML = html;
|
|
6205
6239
|
for (const b of el.querySelectorAll('[data-fsproj]')) b.onclick = (e) => {
|
|
6206
6240
|
if (e.target.closest('.pname.editing') || b.querySelector('.pname.editing')) return;
|
|
6207
6241
|
state.active = b.dataset.fsproj; document.body.classList.remove('projectsopen'); render();
|
|
@@ -6250,6 +6284,14 @@ function fsActiveContext(){
|
|
|
6250
6284
|
// Per-project last non-empty center feed — the whiteout guard (see below): keeps
|
|
6251
6285
|
// what the user was looking at when a render pass momentarily produces no lines.
|
|
6252
6286
|
const _fsLastFeed = {};
|
|
6287
|
+
// H-CENTERLANE decision 1: pure merge-sort of timed HTML blocks (the focused task's
|
|
6288
|
+
// activity block + each chat turn) so the center lane renders in the order things
|
|
6289
|
+
// actually happened. Stable sort (spec-guaranteed since ES2019) keeps same-ts blocks
|
|
6290
|
+
// in their original relative order. Pure/no DOM — covered directly in
|
|
6291
|
+
// engine/test/center-lane-timeline.test.mjs.
|
|
6292
|
+
function fsMergeTimelineBlocks(blocks){
|
|
6293
|
+
return [...blocks].sort((a, b) => a.ts - b.ts).map((b) => b.html).join('');
|
|
6294
|
+
}
|
|
6253
6295
|
function renderFsFeed(){
|
|
6254
6296
|
if (state.layout !== 'flagship') return;
|
|
6255
6297
|
const feed = $('fsFeed'); if (!feed) return;
|
|
@@ -6294,7 +6336,12 @@ function renderFsFeed(){
|
|
|
6294
6336
|
} else { nd.hidden = true; nd.innerHTML = ''; }
|
|
6295
6337
|
}
|
|
6296
6338
|
let html = '';
|
|
6339
|
+
// H-CENTERLANE decision 1: the focused task's activity block is anchored at its
|
|
6340
|
+
// startedAt so it can be merge-sorted against chat turns below (state.act carries
|
|
6341
|
+
// no per-line timestamp, so the whole block — not each line — is the sort unit).
|
|
6342
|
+
let focusTs = 0;
|
|
6297
6343
|
if (focus) {
|
|
6344
|
+
focusTs = focus.startedAt ? Date.parse(focus.startedAt) : 0;
|
|
6298
6345
|
const lines = state.act[focus.id] ?? [];
|
|
6299
6346
|
const lineLimit = fsUI.logOpen.has(focus.id) || focus.status === 'running' || ['running', 'partial'].includes(goal?.status) ? 1000 : 80;
|
|
6300
6347
|
html += lines.slice(-lineLimit).map(fsFeedRow).join('');
|
|
@@ -6361,29 +6408,39 @@ function renderFsFeed(){
|
|
|
6361
6408
|
// whole-feed re-render (SSE act events) can't drop them. Order per turn:
|
|
6362
6409
|
// You → (mark, if it became a task) and, for a reply, speaker line → reply.
|
|
6363
6410
|
const turns = (state.chatTurns[state.active] || []).slice(-40);
|
|
6364
|
-
|
|
6411
|
+
// H-CENTERLANE decision 1: each turn becomes its own timed block (ts, html) so it
|
|
6412
|
+
// can be merge-sorted against the focused task's activity block below, instead of
|
|
6413
|
+
// always rendering as one chat block stapled after the task feed regardless of
|
|
6414
|
+
// when either actually happened.
|
|
6415
|
+
const chatBlocks = [];
|
|
6365
6416
|
for (const turn of turns) {
|
|
6417
|
+
let t = '';
|
|
6366
6418
|
if (turn.role === 'you') {
|
|
6367
|
-
|
|
6419
|
+
t += `<div class="cc-you"><span class="cy-n">You</span><div class="cy-t">${esc(turn.text)}</div></div>`;
|
|
6368
6420
|
if (turn.markGoalId != null && !turn.undone) {
|
|
6369
6421
|
const mg = state.goals.find((x) => x.id === turn.markGoalId);
|
|
6370
6422
|
const n = mg ? goalReviewNumber({ goal: mg, tasks: state.tasks }) : turn.markGoalId;
|
|
6371
|
-
|
|
6423
|
+
t += `<div class="cc-mark"><span class="cm-ar">→</span><span class="cm-t">Added to To Do</span><span class="cm-no">#${n}</span><button class="cm-undo" type="button" data-ccundo="${turn.markGoalId}">Undo</button></div>`;
|
|
6372
6424
|
}
|
|
6373
6425
|
} else { // Claude Code reply — speaker line first (You → speaker → reply), then the text
|
|
6374
6426
|
const app = connectedAppMeta(turn.agent);
|
|
6375
|
-
|
|
6427
|
+
t += `<div class="cc-who">${app.icon}<span class="cs-n">${esc(app.label)}</span></div>`;
|
|
6376
6428
|
const shown = esc((turn.text || '').slice(0, turn.revealed || 0));
|
|
6377
|
-
|
|
6429
|
+
t += turn.done
|
|
6378
6430
|
? `<div class="cc-say">${shown}</div>`
|
|
6379
6431
|
: `<div class="cc-say"><span id="ccTyping">${shown}</span><span class="cc-caret"></span></div>`;
|
|
6380
6432
|
}
|
|
6433
|
+
chatBlocks.push({ ts: turn.ts ?? 0, html: t });
|
|
6381
6434
|
}
|
|
6382
6435
|
const pgoals = state.goals.filter((g) => g.projectId === state.active);
|
|
6383
6436
|
const ptasks = state.tasks.filter((t) => t.projectId === state.active);
|
|
6384
6437
|
const hasWork = pgoals.length > 0 || ptasks.length > 0 || turns.length > 0;
|
|
6385
|
-
//
|
|
6386
|
-
|
|
6438
|
+
// H-CENTERLANE decision 1: interleave the focused task's activity block with the
|
|
6439
|
+
// chat turns in the order they actually happened (stable sort keeps same-ts items
|
|
6440
|
+
// in their original relative order); the question card(s) stay pinned at the very
|
|
6441
|
+
// end (H18/H19 — a pending question must never be lost mid-scroll).
|
|
6442
|
+
const timelineBlocks = html ? [{ ts: focusTs, html }, ...chatBlocks] : chatBlocks;
|
|
6443
|
+
let out = fsMergeTimelineBlocks(timelineBlocks) + askHtml;
|
|
6387
6444
|
if (out) _fsLastFeed[state.active] = out;
|
|
6388
6445
|
else if (hasWork) out = _fsLastFeed[state.active] || `<div class="cc-c">⎿ ${esc(running.length ? 'working…' : 'no active task')}</div>`;
|
|
6389
6446
|
$('fsRoot').classList.toggle('clawdhero', !hasWork);
|
|
@@ -6800,7 +6857,10 @@ function fsBucketTodo(){
|
|
|
6800
6857
|
needsInput: pgoals.filter((g) => g.status === 'needsInput'), // a clarifying question is waiting on the user (board's "Needs you")
|
|
6801
6858
|
reviewGoals: pgoals.filter((g) => g.status === 'review'),
|
|
6802
6859
|
queued: list.filter((t) => t.status === 'queued' && !t.reply).sort(byQueue),
|
|
6803
|
-
|
|
6860
|
+
// A reply task's completion is already visible inline in the center-lane timeline
|
|
6861
|
+
// (H-CENTERLANE decision 3) — showing it again as an independent Done row is the
|
|
6862
|
+
// same event twice. `queued` already guards `!t.reply` above; mirror it here.
|
|
6863
|
+
doneAll: list.filter((t) => ['done', 'skipped'].includes(t.status) && !closedGoalIds.has(t.goalId) && !t.reply).sort((a, b) => b.num - a.num),
|
|
6804
6864
|
later: pgoals.filter((g) => g.status === 'pending'),
|
|
6805
6865
|
rejected: pgoals.filter((g) => g.status === 'rejected'), // 07-20c: Reject parks goals here (own fold, like Done)
|
|
6806
6866
|
};
|
|
@@ -6927,7 +6987,7 @@ function renderFsTodo(){
|
|
|
6927
6987
|
<button class="qadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button>
|
|
6928
6988
|
</div>
|
|
6929
6989
|
${doneAll.length ? `<div class="doneline${fsUI.doneOpen ? ' open' : ''}" id="fsDoneLine">✓ Done<span class="num">${doneAll.length}</span><span class="fold">▾</span></div>
|
|
6930
|
-
<div id="fsDonelist"${fsUI.doneOpen ? '' : ' hidden'}>${doneAll.
|
|
6990
|
+
<div id="fsDonelist"${fsUI.doneOpen ? '' : ' hidden'}>${doneAll.map((t) => `<div class="trow up dim done"><span class="st ok"></span>${noSpan(t.goalId)}<span class="tt">${tt(t.title)}</span></div>`).join('')}</div>` : ''}`;
|
|
6931
6991
|
el.classList.toggle('folded', !!fsUI.todoFold);
|
|
6932
6992
|
$('fsTodoHd').onclick = () => { fsUI.todoFold = !fsUI.todoFold; el.classList.toggle('folded', fsUI.todoFold); };
|
|
6933
6993
|
const qadd = el.querySelector('.qadd[data-fsnew]'); // + New → focus the composer (the real create path)
|
|
@@ -8199,7 +8259,7 @@ async function postOutboxItem(item){
|
|
|
8199
8259
|
// .peraanswer bar. Never land()/upsert(): a kind:'chat' response has no id.
|
|
8200
8260
|
outbox.remove(item.oid);
|
|
8201
8261
|
if (state.layout === 'flagship') {
|
|
8202
|
-
const turn = { role: 'clawd', text: String(goal.reply || ''), revealed: 0, done: false, agent: item.agent };
|
|
8262
|
+
const turn = { role: 'clawd', text: String(goal.reply || ''), revealed: 0, done: false, agent: item.agent, ts: Date.now() };
|
|
8203
8263
|
(state.chatTurns[item.projectId] ||= []).push(turn);
|
|
8204
8264
|
renderFsFeed();
|
|
8205
8265
|
typeChatTurn(turn);
|
|
@@ -9976,7 +10036,7 @@ async function argItems(cmd){
|
|
|
9976
10036
|
// Read the raw input → decide what the palette shows (null = closed).
|
|
9977
10037
|
function paletteState(val){
|
|
9978
10038
|
if (val[0] !== '/') return null;
|
|
9979
|
-
const m = val.match(/^\/(\w*)(\s)?([\s\S]*)$/);
|
|
10039
|
+
const m = val.match(/^\/([\w-]*)(\s)?([\s\S]*)$/); // allow '-' so hyphenated skill names filter (english-practice)
|
|
9980
10040
|
if (!m) return null;
|
|
9981
10041
|
const word = m[1], hadSpace = m[2] != null, rest = m[3];
|
|
9982
10042
|
if (!hadSpace) return { level: 'top', query: word }; // still typing the command name
|
|
@@ -9999,11 +10059,18 @@ function renderPalette(){
|
|
|
9999
10059
|
if (!p || !el) return;
|
|
10000
10060
|
const head = p.level === 'top' ? 'Commands' : '/' + p.cmd;
|
|
10001
10061
|
if (!p.items.length) { el.innerHTML = `<div class="sphead">${esc(head)}</div><div class="empty">No match</div>`; el.classList.add('show'); return; }
|
|
10002
|
-
|
|
10003
|
-
|
|
10004
|
-
|
|
10005
|
-
|
|
10006
|
-
|
|
10062
|
+
let html = '', lastGroup = null;
|
|
10063
|
+
p.items.forEach((it, i) => {
|
|
10064
|
+
// group headers: top-level splits Commands vs Skills; arg-level shows the "/cmd" head once.
|
|
10065
|
+
if (p.level === 'top') {
|
|
10066
|
+
const group = it.skill ? 'Skills' : 'Commands';
|
|
10067
|
+
if (group !== lastGroup) { html += `<div class="sphead">${group}</div>`; lastGroup = group; }
|
|
10068
|
+
} else if (i === 0) { html += `<div class="sphead">${esc('/' + p.cmd)}</div>`; }
|
|
10069
|
+
const name = p.level === 'top' ? (it.skill ? '/' + it.skill : '/' + it.cmd + (it.arg ? ' …' : '')) : it.v;
|
|
10070
|
+
const desc = p.level === 'top' ? (it.desc || '') : (it.d || '');
|
|
10071
|
+
html += `<button type="button" class="spopt${i === p.sel ? ' on' : ''}" data-i="${i}" role="option"><span class="spn">${esc(name)}</span><span class="spd">${esc(desc)}</span></button>`;
|
|
10072
|
+
});
|
|
10073
|
+
el.innerHTML = html;
|
|
10007
10074
|
el.classList.add('show');
|
|
10008
10075
|
for (const b of el.querySelectorAll('[data-i]')) b.onmousedown = (e) => { e.preventDefault(); state.palette.sel = Number(b.dataset.i); acceptPalette(); };
|
|
10009
10076
|
}
|
|
@@ -10015,6 +10082,13 @@ async function refreshPalette(){
|
|
|
10015
10082
|
if (info.level === 'top') {
|
|
10016
10083
|
const q = info.query.toLowerCase();
|
|
10017
10084
|
items = SLASH_CMDS.filter((s) => !q || s.cmd.startsWith(q));
|
|
10085
|
+
// Once the user types after "/", also offer their registered skills (type-to-filter, so a
|
|
10086
|
+
// bare "/" stays a short command list). Selecting one runs the goal with that skill first.
|
|
10087
|
+
if (q) {
|
|
10088
|
+
const skills = (await loadSkills()).filter((s) => s.name.toLowerCase().includes(q));
|
|
10089
|
+
if ($('input').value !== val) return; // input changed while awaiting skills
|
|
10090
|
+
items = items.concat(skills.map((s) => ({ skill: s.name, desc: `${s.kind || 'skill'}${s.description ? ' · ' + s.description : ''}`.trim() })));
|
|
10091
|
+
}
|
|
10018
10092
|
} else {
|
|
10019
10093
|
const all = await argItems(info.cmd);
|
|
10020
10094
|
if ($('input').value !== val) return; // input changed while awaiting skills
|
|
@@ -10035,7 +10109,8 @@ function acceptPalette(){
|
|
|
10035
10109
|
const p = state.palette; if (!p) return false;
|
|
10036
10110
|
const item = p.items[p.sel]; if (!item) return true;
|
|
10037
10111
|
if (p.level === 'top') {
|
|
10038
|
-
if (item.
|
|
10112
|
+
if (item.skill) { applySlash('skill', item.skill); $('input').value = $('input').value.replace(/^\s*\/\S*\s*/, ''); closePalette(); } // hyphen-safe strip of the "/skillname" token
|
|
10113
|
+
else if (item.arg) { $('input').value = '/' + item.cmd + ' '; refreshPalette(); }
|
|
10039
10114
|
else { applySlash(item.cmd, null); $('input').value = stripLeadingCmd($('input').value); closePalette(); }
|
|
10040
10115
|
} else {
|
|
10041
10116
|
applySlash(p.cmd, item.v);
|
package/engine/server.mjs
CHANGED
|
@@ -728,13 +728,22 @@ if (process.env.MANAGER_SEED_RUNNING_FIXTURE === '1') {
|
|
|
728
728
|
// res -> identity (see identityFor), captured at connection time so every
|
|
729
729
|
// SSE push can be scoped per-connection without re-deriving auth per event.
|
|
730
730
|
const sseClients = new Map();
|
|
731
|
-
// UI hot-reload: open tabs compare this stamp (index.html mtime
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
|
|
731
|
+
// UI hot-reload: open tabs compare this stamp (index.html mtime only) via
|
|
732
|
+
// /api/state and the 30s SSE ping, and reload themselves when a new UI ships
|
|
733
|
+
// — no manual reload after a hot-copy.
|
|
734
|
+
//
|
|
735
|
+
// It used to also fold in a per-process BOOT_STAMP (Date.now() at boot) so a
|
|
736
|
+
// bare restart would force a reload too. That backfired on the always-on
|
|
737
|
+
// resident: every relay-supervisor respawn / hot-copy restart, and every
|
|
738
|
+
// reconnect that landed on a *different* engine process (each with its own
|
|
739
|
+
// BOOT_STAMP), changed uiVersion and yanked a `location.reload()` on the 30s
|
|
740
|
+
// ping — the "reloads every 30-60s and resets to the default project" report
|
|
741
|
+
// (Masa 2026-07-24). mtime alone means a reload fires ONLY when app/index.html
|
|
742
|
+
// actually changes, and every process serving the same file agrees on the
|
|
743
|
+
// stamp, so restarts and multi-process relay churn no longer reload the tab.
|
|
735
744
|
function uiVersion() {
|
|
736
|
-
try { return
|
|
737
|
-
catch { return
|
|
745
|
+
try { return String(Math.round(statSync(join(ROOT, 'app', 'index.html')).mtimeMs)); }
|
|
746
|
+
catch { return '0'; }
|
|
738
747
|
}
|
|
739
748
|
|
|
740
749
|
// Broadcast to every connected client, unscoped — only for events that carry
|
|
@@ -4330,7 +4339,16 @@ const server = createServer(async (req, res) => {
|
|
|
4330
4339
|
try {
|
|
4331
4340
|
const { text } = JSON.parse(body);
|
|
4332
4341
|
if (!text?.trim()) return json(res, 400, { error: 'text required' });
|
|
4333
|
-
|
|
4342
|
+
// Reply picks a parked goal back up into the work queue. 'review' MUST be
|
|
4343
|
+
// here: a completed goal awaiting your approve/reject is 'review', and a
|
|
4344
|
+
// reply to it means "not done — keep working" (Masa 2026-07-24: replied to
|
|
4345
|
+
// an in-review card and it sat stuck in Review with no worker activity).
|
|
4346
|
+
// Without this flip the reply task was queued but the goal never left the
|
|
4347
|
+
// Review lane, so nothing looked like it was happening. Un-parking mirrors
|
|
4348
|
+
// the already-working 'blocked'/'failed'/etc. "Needs you" replies: the goal
|
|
4349
|
+
// flips to running and, since it still carries startedAt from its first run,
|
|
4350
|
+
// shows in Doing as "Working on it" — then the worker resumes its session.
|
|
4351
|
+
if (['review', 'partial', 'failed', 'interrupted', 'blocked'].includes(goal.status)) {
|
|
4334
4352
|
goal.status = 'running';
|
|
4335
4353
|
goal.blocked = null;
|
|
4336
4354
|
saveGoal(goal);
|
package/package.json
CHANGED