@worca/app 1.1.1 → 1.2.0-rc.2
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/README.md +23 -1
- package/package.json +1 -1
- package/src/cli/worca-cc.mjs +247 -27
- package/src/core/artifacts.mjs +10 -2
- package/src/core/ask/attachment-kind.mjs +95 -0
- package/src/core/ask/events.mjs +42 -3
- package/src/core/ask/follow.mjs +10 -4
- package/src/core/ask/limits.mjs +6 -3
- package/src/core/ask/prompt.mjs +37 -12
- package/src/core/ask/spawn.mjs +6 -3
- package/src/core/ask/store.mjs +89 -11
- package/src/core/ask/tool-deps.mjs +27 -3
- package/src/core/ask/tools.mjs +41 -10
- package/src/core/ask/turn.mjs +58 -12
- package/src/core/chat/command-router.mjs +8 -4
- package/src/core/chat/notifier.mjs +6 -1
- package/src/core/chat/renderers.mjs +15 -8
- package/src/core/claude-runner.mjs +120 -18
- package/src/core/config.mjs +46 -3
- package/src/core/db.mjs +92 -9
- package/src/core/failure-policy.mjs +201 -0
- package/src/core/graph/scheduler.mjs +8 -1
- package/src/core/host-guard.mjs +271 -0
- package/src/core/model-env.mjs +68 -0
- package/src/core/orchestrator.mjs +128 -35
- package/src/core/plugin-shim.mjs +3 -3
- package/src/core/run-harness.mjs +410 -61
- package/src/core/settings.mjs +76 -1
- package/src/core/ui-instance.mjs +235 -0
- package/ui/public/app.js +259 -39
- package/ui/public/ask-model.mjs +60 -7
- package/ui/public/ask-panel.mjs +314 -65
- package/ui/public/index.html +42 -0
- package/ui/public/style.css +28 -0
- package/ui/server.mjs +377 -80
package/ui/public/app.js
CHANGED
|
@@ -626,6 +626,12 @@ function handleServerMessage(msg) {
|
|
|
626
626
|
refreshBudget();
|
|
627
627
|
return;
|
|
628
628
|
}
|
|
629
|
+
// Another tab saved a Settings card: repaint ours from the server so a stale
|
|
630
|
+
// checkbox/field cannot be "saved" back over the change.
|
|
631
|
+
if (msg.type === 'settings-changed') {
|
|
632
|
+
loadSettings();
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
629
635
|
if (msg.type === 'projects-changed') {
|
|
630
636
|
refreshAllCounts();
|
|
631
637
|
if (currentView() === 'projects') loadProjectsView();
|
|
@@ -770,6 +776,7 @@ function onHello(msg) {
|
|
|
770
776
|
kind: r0.kind || 'run',
|
|
771
777
|
pipelineId: r0.pipelineId || null,
|
|
772
778
|
pauseReason: r0.pauseReason || null,
|
|
779
|
+
pauseDetail: r0.pauseDetail || null,
|
|
773
780
|
workspaceId: r0.workspaceId || undefined,
|
|
774
781
|
projectNames: Array.isArray(r0.projectNames) && r0.projectNames.length ? r0.projectNames : undefined,
|
|
775
782
|
});
|
|
@@ -898,6 +905,7 @@ function nowHMS() {
|
|
|
898
905
|
function makeRun({
|
|
899
906
|
runId, title, projectDir, status = 'running', startedAt, local = false,
|
|
900
907
|
pendingQuestion = null, kind = 'run', pipelineId = null, pauseReason = null,
|
|
908
|
+
pauseDetail = null,
|
|
901
909
|
workspaceId = undefined, workspaceName = undefined, projectNames = null,
|
|
902
910
|
}) {
|
|
903
911
|
return {
|
|
@@ -912,6 +920,7 @@ function makeRun({
|
|
|
912
920
|
pipelineId, // matches a History row id once persisted; used to hide lingerers from History
|
|
913
921
|
pauseReason, // why it paused, or null — ANY orchestrator pause code rides here
|
|
914
922
|
// (e.g. 'usage_limit'); only the cost pair renders a cost banner
|
|
923
|
+
pauseDetail, // the human-readable cause behind an 'error' pause, or null
|
|
915
924
|
workspaceId,
|
|
916
925
|
workspaceName,
|
|
917
926
|
// Stable ordering key: assigned once per runId, never bumped by activity
|
|
@@ -4054,7 +4063,8 @@ function renderGateBody(r, panel, pq) {
|
|
|
4054
4063
|
}
|
|
4055
4064
|
|
|
4056
4065
|
// Recovery prompt: a node hit a recoverable error (auth / rate-limit / quota /
|
|
4057
|
-
// network). Show the cause and let the user fix it then Retry, or
|
|
4066
|
+
// network). Show the cause and let the user fix it then Retry, or park the run
|
|
4067
|
+
// with Pause run — nothing here ends a run, only Stop does.
|
|
4058
4068
|
function renderRecoveryBody(r, panel, pq) {
|
|
4059
4069
|
const rec = pq.recovery || {};
|
|
4060
4070
|
const intro = document.createElement('div');
|
|
@@ -4062,7 +4072,7 @@ function renderRecoveryBody(r, panel, pq) {
|
|
|
4062
4072
|
const hint = rec.cls === 'auth'
|
|
4063
4073
|
? 'Re-authenticate (e.g. run `claude setup-token` or `/login`), then Retry.'
|
|
4064
4074
|
: 'Fix the problem (wait out a limit, restore connectivity, top up credit), then Retry.';
|
|
4065
|
-
intro.textContent = `This step could not reach the model. ${hint}
|
|
4075
|
+
intro.textContent = `This step could not reach the model. ${hint} Or pause the run and come back later — only Stop ends it.`;
|
|
4066
4076
|
panel.appendChild(intro);
|
|
4067
4077
|
|
|
4068
4078
|
if (rec.message) {
|
|
@@ -4074,15 +4084,21 @@ function renderRecoveryBody(r, panel, pq) {
|
|
|
4074
4084
|
|
|
4075
4085
|
const foot = document.createElement('div');
|
|
4076
4086
|
foot.className = 'qpanel-foot gate-actions';
|
|
4077
|
-
|
|
4078
|
-
abort.
|
|
4079
|
-
|
|
4080
|
-
|
|
4087
|
+
// The give-up option comes from the failure policy's row (options ride the
|
|
4088
|
+
// prompt): 'pause' parks the run, 'abort' ends it. Older payloads carry none.
|
|
4089
|
+
const giveUp = (Array.isArray(rec.options) && rec.options.find((o) => o && o.id !== 'retry')) || { id: 'pause' };
|
|
4090
|
+
const pause = document.createElement('button');
|
|
4091
|
+
pause.type = 'button';
|
|
4092
|
+
pause.className = giveUp.id === 'abort' ? 'btn recovery-abort' : 'btn recovery-pause';
|
|
4093
|
+
pause.textContent = giveUp.id === 'abort' ? 'Abort run' : 'Pause run';
|
|
4094
|
+
pause.title = giveUp.id === 'abort'
|
|
4095
|
+
? 'End the run here with this error. The worktree is torn down.'
|
|
4096
|
+
: 'Park the run here with this error as the reason. Nothing is discarded — Resume retries the step later.';
|
|
4081
4097
|
const retry = document.createElement('button');
|
|
4082
4098
|
retry.type = 'button';
|
|
4083
4099
|
retry.className = 'btn btn-primary recovery-retry';
|
|
4084
4100
|
retry.textContent = 'Retry';
|
|
4085
|
-
foot.append(
|
|
4101
|
+
foot.append(pause, retry);
|
|
4086
4102
|
panel.appendChild(foot);
|
|
4087
4103
|
}
|
|
4088
4104
|
|
|
@@ -4269,6 +4285,9 @@ function onDone(r, msg) {
|
|
|
4269
4285
|
// unconditionally so a later reasonless done clears it, matching the server's
|
|
4270
4286
|
// own entry.pauseReason reset in wireRun.
|
|
4271
4287
|
r.pauseReason = msg.reason || null;
|
|
4288
|
+
// An 'error' pause also carries the cause it parked on; assigned unconditionally
|
|
4289
|
+
// for the same reason as the code above — a later reasonless done must clear it.
|
|
4290
|
+
r.pauseDetail = msg.detail || null;
|
|
4272
4291
|
finishRun(r, msg.status || 'done');
|
|
4273
4292
|
// Nothing else picks up the FINAL spend delta: a non-cost `done` broadcasts no
|
|
4274
4293
|
// budget-changed, and startBudgetTick refetches only while runs are live. Without
|
|
@@ -7682,9 +7701,11 @@ async function loadSettings() {
|
|
|
7682
7701
|
paintSettings(data);
|
|
7683
7702
|
paintBudgetSettings(data);
|
|
7684
7703
|
paintAskSettings(data);
|
|
7704
|
+
paintDebugSpawnSettings(data);
|
|
7685
7705
|
paintBudgetReadout();
|
|
7686
7706
|
refreshBudget();
|
|
7687
7707
|
paintChatSettings(data.chat);
|
|
7708
|
+
loadAskHistory();
|
|
7688
7709
|
setSettingsMsg('');
|
|
7689
7710
|
} catch (e) { setSettingsMsg(e.message, 'err'); }
|
|
7690
7711
|
}
|
|
@@ -7962,10 +7983,27 @@ if (el.budgetReset) {
|
|
|
7962
7983
|
}
|
|
7963
7984
|
|
|
7964
7985
|
// ---- Ask Worca limits card (budget-card pattern above) ---------------------
|
|
7965
|
-
|
|
7966
|
-
|
|
7986
|
+
// Shared by the small Settings cards (Ask Worca, Spawn diagnostics): one hint
|
|
7987
|
+
// setter and one "POST /api/settings → parse → paint or show the error" routine,
|
|
7988
|
+
// so a fix to the fetch/parse/error path lands in every card at once.
|
|
7989
|
+
function setHintMsg(id, text, kind) {
|
|
7990
|
+
const n = document.getElementById(id);
|
|
7967
7991
|
if (n) { n.textContent = text || ''; n.className = `hint${kind ? ` ${kind}` : ''}`; }
|
|
7968
7992
|
}
|
|
7993
|
+
async function postSettingsCard(body, { setMsg, paint, savedText = 'Saved.' }) {
|
|
7994
|
+
setMsg('');
|
|
7995
|
+
let res;
|
|
7996
|
+
try {
|
|
7997
|
+
res = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
7998
|
+
} catch (e) { setMsg(e.message || 'network error', 'err'); return; }
|
|
7999
|
+
const data = await safeJson(res);
|
|
8000
|
+
if (!res.ok) { setMsg(data.error || `HTTP ${res.status}`, 'err'); return; }
|
|
8001
|
+
// A 2xx with an unparsable body yields {} — leave the card as the user set it
|
|
8002
|
+
// rather than painting every field as "unset".
|
|
8003
|
+
if (Object.keys(data).length) paint(data);
|
|
8004
|
+
setMsg(savedText);
|
|
8005
|
+
}
|
|
8006
|
+
function setAskLimitsMsg(text, kind) { setHintMsg('askLimitsMsg', text, kind); }
|
|
7969
8007
|
function paintAskSettings(data) {
|
|
7970
8008
|
const turns = document.getElementById('askMaxTurns');
|
|
7971
8009
|
const budget = document.getElementById('askMaxBudgetUsd');
|
|
@@ -7976,17 +8014,8 @@ function paintAskSettings(data) {
|
|
|
7976
8014
|
budget.disabled = noCap.checked;
|
|
7977
8015
|
budget.value = data.askMaxBudgetUsd == null ? '' : String(data.askMaxBudgetUsd);
|
|
7978
8016
|
}
|
|
7979
|
-
|
|
7980
|
-
setAskLimitsMsg
|
|
7981
|
-
let res = null;
|
|
7982
|
-
try {
|
|
7983
|
-
res = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
7984
|
-
} catch { setAskLimitsMsg('network error', 'err'); return; }
|
|
7985
|
-
let data = null;
|
|
7986
|
-
try { data = await res.json(); } catch { data = null; }
|
|
7987
|
-
if (!res.ok) { setAskLimitsMsg((data && data.error) || `save failed (${res.status})`, 'err'); return; }
|
|
7988
|
-
paintAskSettings(data || {});
|
|
7989
|
-
setAskLimitsMsg('Saved.');
|
|
8017
|
+
function postAskLimits(body) {
|
|
8018
|
+
return postSettingsCard(body, { setMsg: setAskLimitsMsg, paint: paintAskSettings });
|
|
7990
8019
|
}
|
|
7991
8020
|
function saveAskLimits() {
|
|
7992
8021
|
const turnsRaw = document.getElementById('askMaxTurns').value.trim();
|
|
@@ -8014,6 +8043,108 @@ document.getElementById('askNoCap')?.addEventListener('change', () => {
|
|
|
8014
8043
|
if (budget) budget.disabled = document.getElementById('askNoCap').checked;
|
|
8015
8044
|
});
|
|
8016
8045
|
|
|
8046
|
+
// ---- Ask Worca chat history (same card, same hint pattern) -----------------
|
|
8047
|
+
// The counts line paints with the settings view; the destructive button refetches
|
|
8048
|
+
// GET /api/ask/history so the dialog quotes what is about to go, then one bulk
|
|
8049
|
+
// DELETE /api/ask/threads. The server broadcasts ask-history-cleared afterwards
|
|
8050
|
+
// — the ask panel resets itself off that frame, nothing to do here.
|
|
8051
|
+
function setAskHistoryMsg(text, kind) {
|
|
8052
|
+
const n = document.getElementById('askHistoryMsg');
|
|
8053
|
+
if (n) { n.textContent = text || ''; n.className = `hint${kind ? ` ${kind}` : ''}`; }
|
|
8054
|
+
}
|
|
8055
|
+
function askHistoryCount(n, one, many = `${one}s`) {
|
|
8056
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
8057
|
+
}
|
|
8058
|
+
function normalizeAskHistory(data) {
|
|
8059
|
+
const num = (v) => (Number.isInteger(v) && v > 0 ? v : 0);
|
|
8060
|
+
return { threads: num(data?.threads), worktrees: num(data?.worktrees), attachments: num(data?.attachments), inFlight: num(data?.inFlight) };
|
|
8061
|
+
}
|
|
8062
|
+
function paintAskHistory(counts) {
|
|
8063
|
+
const line = document.getElementById('askHistoryCounts');
|
|
8064
|
+
const btn = document.getElementById('askHistoryDelete');
|
|
8065
|
+
if (!line || !btn) return;
|
|
8066
|
+
line.textContent = counts.threads
|
|
8067
|
+
? `${askHistoryCount(counts.threads, 'chat')} · ${askHistoryCount(counts.worktrees, 'worktree')}`
|
|
8068
|
+
: 'No saved chats.';
|
|
8069
|
+
btn.disabled = counts.threads === 0;
|
|
8070
|
+
}
|
|
8071
|
+
async function fetchAskHistory() {
|
|
8072
|
+
const res = await fetch('/api/ask/history');
|
|
8073
|
+
const data = await safeJson(res);
|
|
8074
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
8075
|
+
return normalizeAskHistory(data);
|
|
8076
|
+
}
|
|
8077
|
+
async function loadAskHistory() {
|
|
8078
|
+
if (!document.getElementById('askHistoryCounts')) return;
|
|
8079
|
+
try { paintAskHistory(await fetchAskHistory()); } catch (e) { setAskHistoryMsg(e.message, 'err'); }
|
|
8080
|
+
}
|
|
8081
|
+
// One bullet per non-zero count; the in-progress line only when a turn is live.
|
|
8082
|
+
function askHistoryConfirmMessage(c) {
|
|
8083
|
+
const lines = ['This permanently deletes all Ask Worca chat history:'];
|
|
8084
|
+
if (c.threads) lines.push(`• ${askHistoryCount(c.threads, 'chat thread')} and ${c.threads === 1 ? 'its transcript' : 'their transcripts'}`);
|
|
8085
|
+
if (c.worktrees) {
|
|
8086
|
+
lines.push(`• ${askHistoryCount(c.worktrees, 'git worktree')} checked out for ${c.threads === 1 ? 'that chat' : 'those chats'} (removed from ${c.worktrees === 1 ? 'its source repo' : 'their source repos'})`);
|
|
8087
|
+
}
|
|
8088
|
+
if (c.attachments) lines.push(`• ${askHistoryCount(c.attachments, 'attachment')}`);
|
|
8089
|
+
if (c.inFlight) lines.push(`• ${askHistoryCount(c.inFlight, 'chat')} currently in progress will be stopped`);
|
|
8090
|
+
lines.push('Runs started from these chats are not affected. This cannot be undone.');
|
|
8091
|
+
return lines.join('\n');
|
|
8092
|
+
}
|
|
8093
|
+
async function deleteAskHistory() {
|
|
8094
|
+
setAskHistoryMsg('');
|
|
8095
|
+
let counts;
|
|
8096
|
+
try { counts = await fetchAskHistory(); } catch (e) { setAskHistoryMsg(e.message, 'err'); return; }
|
|
8097
|
+
paintAskHistory(counts);
|
|
8098
|
+
if (!counts.threads) return; // emptied meanwhile — nothing to confirm
|
|
8099
|
+
const ok = await confirmModal({
|
|
8100
|
+
title: 'Delete all chat history?',
|
|
8101
|
+
message: askHistoryConfirmMessage(counts),
|
|
8102
|
+
confirmLabel: 'Delete everything',
|
|
8103
|
+
danger: true,
|
|
8104
|
+
});
|
|
8105
|
+
if (!ok) return;
|
|
8106
|
+
let res = null;
|
|
8107
|
+
try {
|
|
8108
|
+
res = await fetch('/api/ask/threads', { method: 'DELETE' });
|
|
8109
|
+
} catch { setAskHistoryMsg('network error', 'err'); return; }
|
|
8110
|
+
const data = await safeJson(res);
|
|
8111
|
+
if (!res.ok) { setAskHistoryMsg(data.error || `HTTP ${res.status}`, 'err'); await loadAskHistory(); return; }
|
|
8112
|
+
const removed = normalizeAskHistory(data.removed);
|
|
8113
|
+
const failed = Array.isArray(data.failed) ? data.failed.length : 0;
|
|
8114
|
+
const summary = `Deleted ${askHistoryCount(removed.threads, 'chat')} and ${askHistoryCount(removed.worktrees, 'worktree')}.`;
|
|
8115
|
+
if (failed) setAskHistoryMsg(`${summary} ${askHistoryCount(failed, 'chat')} could not be removed.`, 'err');
|
|
8116
|
+
else setAskHistoryMsg(summary);
|
|
8117
|
+
await loadAskHistory();
|
|
8118
|
+
}
|
|
8119
|
+
document.getElementById('askHistoryDelete')?.addEventListener('click', deleteAskHistory);
|
|
8120
|
+
|
|
8121
|
+
// ---- Spawn-debug diagnostics card (shares the Ask card's helpers above) ----
|
|
8122
|
+
function setDebugSpawnMsg(text, kind) { setHintMsg('debugSpawnMsg', text, kind); }
|
|
8123
|
+
// The checkbox is the STORED preference; the note says when the environment
|
|
8124
|
+
// overrides it (a non-empty WORCA_DEBUG_SPAWN at launch), so an operator never
|
|
8125
|
+
// sees an unchecked box while diagnostics are flowing — or the reverse.
|
|
8126
|
+
function paintDebugSpawnSettings(data) {
|
|
8127
|
+
const cb = document.getElementById('debugSpawnEnabled');
|
|
8128
|
+
if (!cb) return;
|
|
8129
|
+
cb.checked = !!data.debugSpawnEnabled;
|
|
8130
|
+
const eff = data.debugSpawnEffective;
|
|
8131
|
+
const envOverride = !!eff && eff.source === 'env';
|
|
8132
|
+
setHintMsg('debugSpawnEnvNote', envOverride
|
|
8133
|
+
? `WORCA_DEBUG_SPAWN is set in the environment: diagnostics are ${eff.enabled ? 'ON' : 'OFF'} regardless of this setting.`
|
|
8134
|
+
: '', envOverride ? 'warn' : '');
|
|
8135
|
+
}
|
|
8136
|
+
function postDebugSpawn(body) {
|
|
8137
|
+
return postSettingsCard(body, {
|
|
8138
|
+
setMsg: setDebugSpawnMsg, paint: paintDebugSpawnSettings,
|
|
8139
|
+
savedText: 'Saved. Applies to the next spawn — no restart needed.',
|
|
8140
|
+
});
|
|
8141
|
+
}
|
|
8142
|
+
function saveDebugSpawn() {
|
|
8143
|
+
postDebugSpawn({ debugSpawnEnabled: document.getElementById('debugSpawnEnabled').checked });
|
|
8144
|
+
}
|
|
8145
|
+
document.getElementById('debugSpawnSave')?.addEventListener('click', saveDebugSpawn);
|
|
8146
|
+
document.getElementById('debugSpawnReset')?.addEventListener('click', () => postDebugSpawn({ debugSpawnEnabled: false }));
|
|
8147
|
+
|
|
8017
8148
|
// Browse… for the projects root: native OS dialog, in-app modal fallback —
|
|
8018
8149
|
// the same two endpoints the add-project Browse button uses (app.js:3793).
|
|
8019
8150
|
if (el.settingsProjectsRootBrowse) {
|
|
@@ -9435,7 +9566,7 @@ if (runListEl) {
|
|
|
9435
9566
|
|
|
9436
9567
|
// qpanel actions. Resolve the run per-card via the enclosing .run-card so
|
|
9437
9568
|
// delegation works for any dynamically-built card.
|
|
9438
|
-
const qbtn = e.target.closest && e.target.closest('.qpanel .btn-go, .qpanel .gate-continue, .qpanel .gate-another, .qpanel .recovery-retry, .qpanel .recovery-abort');
|
|
9569
|
+
const qbtn = e.target.closest && e.target.closest('.qpanel .btn-go, .qpanel .gate-continue, .qpanel .gate-another, .qpanel .recovery-retry, .qpanel .recovery-pause, .qpanel .recovery-abort');
|
|
9439
9570
|
if (qbtn) {
|
|
9440
9571
|
const card = qbtn.closest('.run-card');
|
|
9441
9572
|
const runId = card && card.dataset.runId;
|
|
@@ -9444,6 +9575,7 @@ if (runListEl) {
|
|
|
9444
9575
|
if (qbtn.classList.contains('gate-continue')) postAnswer(r, { decision: 'continue' });
|
|
9445
9576
|
else if (qbtn.classList.contains('gate-another')) postAnswer(r, { decision: 'another' });
|
|
9446
9577
|
else if (qbtn.classList.contains('recovery-retry')) postAnswer(r, { decision: 'retry' });
|
|
9578
|
+
else if (qbtn.classList.contains('recovery-pause')) postAnswer(r, { decision: 'pause' });
|
|
9447
9579
|
else if (qbtn.classList.contains('recovery-abort')) postAnswer(r, { decision: 'abort' });
|
|
9448
9580
|
else submitAnswer(r, qbtn.closest('.qpanel'));
|
|
9449
9581
|
}
|
|
@@ -10288,12 +10420,19 @@ const PAUSED_STATUSES = ['paused', 'pausing', 'interrupted'];
|
|
|
10288
10420
|
// Disable a history Resume button while a total-budget pause is still blocked by
|
|
10289
10421
|
// the current window. Shared by setupHdActions (first paint) and
|
|
10290
10422
|
// refreshHistResumeGating (every later budget change).
|
|
10291
|
-
function applyHistResumeGate(btn, pauseReason, budget) {
|
|
10423
|
+
function applyHistResumeGate(btn, pauseReason, budget, pauseDetail = '') {
|
|
10292
10424
|
const totalBlocked = pauseReason === 'cost_total' && !!(budget && budget.blocked);
|
|
10293
10425
|
btn.disabled = totalBlocked;
|
|
10294
|
-
|
|
10295
|
-
|
|
10296
|
-
|
|
10426
|
+
if (totalBlocked) {
|
|
10427
|
+
btn.title = `Total budget reached — blocked until ${fmtResetAtLocal(budget.windowEndMs)} or a higher total limit`;
|
|
10428
|
+
} else if (pauseReason === 'error' || pauseReason === 'recoverable') {
|
|
10429
|
+
// An error pause is never gated — the tooltip carries the cause instead.
|
|
10430
|
+
btn.title = pauseReason === 'recoverable'
|
|
10431
|
+
? `Paused on a recoverable error${pauseDetail ? `: ${pauseDetail}` : ''} — resume once it clears`
|
|
10432
|
+
: `Paused after an error${pauseDetail ? `: ${pauseDetail}` : ''} — fix the cause, then resume`;
|
|
10433
|
+
} else {
|
|
10434
|
+
btn.title = '';
|
|
10435
|
+
}
|
|
10297
10436
|
}
|
|
10298
10437
|
|
|
10299
10438
|
// Re-gate the mounted history Resume button from the dataset.pauseReason stamp
|
|
@@ -10312,7 +10451,7 @@ function refreshHistResumeGating() {
|
|
|
10312
10451
|
// otherwise a `cost_total` block that lands later leaves the button enabled and
|
|
10313
10452
|
// the user clicks into a guaranteed 403. Re-gate, then restore the D3 error
|
|
10314
10453
|
// title when gating did not take the button away.
|
|
10315
|
-
applyHistResumeGate(btn, root.dataset.pauseReason || '', budgetState.budget);
|
|
10454
|
+
applyHistResumeGate(btn, root.dataset.pauseReason || '', budgetState.budget, root.dataset.pauseDetail || '');
|
|
10316
10455
|
if (btn.dataset.resumeState === 'error' && btn.dataset.resumeError && !btn.disabled) {
|
|
10317
10456
|
btn.title = btn.dataset.resumeError;
|
|
10318
10457
|
}
|
|
@@ -10434,14 +10573,21 @@ function buildHistCard(projectDir, p, ghAvailable = false) {
|
|
|
10434
10573
|
// Pause note. Resume + its budget gating live on the detail page now; the
|
|
10435
10574
|
// dataset stamp survives for parity/debugging.
|
|
10436
10575
|
const pauseReason = typeof p.pauseReason === 'string' ? p.pauseReason : '';
|
|
10576
|
+
const pauseDetail = typeof p.pauseDetail === 'string' ? p.pauseDetail : '';
|
|
10437
10577
|
if (pauseReason) node.dataset.pauseReason = pauseReason;
|
|
10578
|
+
if (pauseDetail) node.dataset.pauseDetail = pauseDetail;
|
|
10438
10579
|
const noteEl = node.querySelector('.hist-pausenote');
|
|
10439
|
-
const
|
|
10440
|
-
|
|
10441
|
-
|
|
10580
|
+
const parked = PAUSED_STATUSES.includes(String(p.status || '').toLowerCase());
|
|
10581
|
+
const costPaused = parked && pauseReason.startsWith('cost_');
|
|
10582
|
+
const errorPaused = parked && (pauseReason === 'error' || pauseReason === 'recoverable');
|
|
10583
|
+
noteEl.hidden = !(costPaused || errorPaused);
|
|
10442
10584
|
noteEl.textContent = costPaused
|
|
10443
|
-
? (pauseReason === 'cost_total' ? 'paused · total budget' : 'paused · cost limit')
|
|
10585
|
+
? (pauseReason === 'cost_total' ? 'paused · total budget' : 'paused · cost limit')
|
|
10586
|
+
: (errorPaused ? (pauseReason === 'recoverable' ? 'paused · recoverable' : 'paused · error') : '');
|
|
10587
|
+
// The cause is too long for the caption line — it rides as the tooltip.
|
|
10588
|
+
noteEl.title = errorPaused ? pauseDetail : '';
|
|
10444
10589
|
noteEl.classList.toggle('total', costPaused && pauseReason === 'cost_total');
|
|
10590
|
+
noteEl.classList.toggle('error', errorPaused);
|
|
10445
10591
|
|
|
10446
10592
|
renderRetainedWork(node, p); // badge only — the card has no banner node
|
|
10447
10593
|
setupPrButton(node, projectDir, p, ghAvailable);
|
|
@@ -11466,14 +11612,48 @@ function hdRetainedFor(record, st) {
|
|
|
11466
11612
|
return { retained: derived, provisional: true };
|
|
11467
11613
|
}
|
|
11468
11614
|
|
|
11615
|
+
// Error-pause banner (D11): the cause, and the promise that nothing was discarded.
|
|
11616
|
+
function renderErrorPauseBanner(detail, reason = 'error') {
|
|
11617
|
+
const recoverable = reason === 'recoverable';
|
|
11618
|
+
const el = document.createElement('div');
|
|
11619
|
+
el.className = 'pause-error-banner';
|
|
11620
|
+
el.dataset.reason = reason;
|
|
11621
|
+
const b = document.createElement('b');
|
|
11622
|
+
b.textContent = recoverable ? 'Paused on a recoverable error' : 'Paused after an error';
|
|
11623
|
+
const text = document.createElement('div');
|
|
11624
|
+
text.className = 'peb-text';
|
|
11625
|
+
text.textContent = detail || 'The run hit an error it could not recover from.';
|
|
11626
|
+
const hint = document.createElement('div');
|
|
11627
|
+
hint.className = 'peb-hint';
|
|
11628
|
+
hint.textContent = recoverable
|
|
11629
|
+
? 'The run could not reach the model and parked itself. Nothing was discarded — once the cause clears (re-login, connectivity, credit, a rate limit), Resume retries the step.'
|
|
11630
|
+
: 'Nothing was discarded: the worktree and the run position are kept. Fix the cause, then Resume.';
|
|
11631
|
+
el.append(b, text, hint);
|
|
11632
|
+
return el;
|
|
11633
|
+
}
|
|
11634
|
+
|
|
11469
11635
|
function paintHdBanners(screen, record, data) {
|
|
11470
11636
|
const st = data.state;
|
|
11471
11637
|
const banners = screen.querySelector('.hd-banners');
|
|
11472
11638
|
|
|
11473
|
-
//
|
|
11474
|
-
//
|
|
11475
|
-
|
|
11639
|
+
// Pause banners. The LIST row is authoritative, but a deep link has only the
|
|
11640
|
+
// stub until the row lands — so fall back to the DETAIL payload, which now
|
|
11641
|
+
// carries both keys too. Rebuild idempotently instead of once.
|
|
11642
|
+
const pauseReason = typeof record.pauseReason === 'string' ? record.pauseReason
|
|
11643
|
+
: (typeof st.pauseReason === 'string' ? st.pauseReason : '');
|
|
11476
11644
|
if (pauseReason) screen.dataset.pauseReason = pauseReason; else delete screen.dataset.pauseReason;
|
|
11645
|
+
const pauseDetail = typeof record.pauseDetail === 'string' ? record.pauseDetail
|
|
11646
|
+
: (typeof st.pauseDetail === 'string' ? st.pauseDetail : '');
|
|
11647
|
+
if (pauseDetail) screen.dataset.pauseDetail = pauseDetail; else delete screen.dataset.pauseDetail;
|
|
11648
|
+
// Keyed on the detail so a corrected cause replaces the banner instead of stacking.
|
|
11649
|
+
const wantErr = (pauseReason === 'error' || pauseReason === 'recoverable') && HD_RESUMABLE.has(String(st.status || '').toLowerCase());
|
|
11650
|
+
const oldErr = banners.querySelector('.pause-error-banner');
|
|
11651
|
+
if (oldErr && (!wantErr || oldErr.dataset.detail !== pauseDetail || oldErr.dataset.reason !== pauseReason)) oldErr.remove();
|
|
11652
|
+
if (wantErr && !banners.querySelector('.pause-error-banner')) {
|
|
11653
|
+
const errBanner = renderErrorPauseBanner(pauseDetail, pauseReason);
|
|
11654
|
+
errBanner.dataset.detail = pauseDetail;
|
|
11655
|
+
banners.prepend(errBanner);
|
|
11656
|
+
}
|
|
11477
11657
|
// Rebuild the cost banner ONLY when the reason actually changed. An
|
|
11478
11658
|
// unconditional remove+rebuild detaches the `.cb-override` button mid-flight:
|
|
11479
11659
|
// that click awaits confirmModal then resumePipeline, and ANY paintHistory()
|
|
@@ -11585,7 +11765,7 @@ function setupHdActions(screen, record, data) {
|
|
|
11585
11765
|
const resumeBtn = screen.querySelector('.hd-resume');
|
|
11586
11766
|
if (HD_RESUMABLE.has(status) && st.resumable !== false) {
|
|
11587
11767
|
resumeBtn.hidden = false;
|
|
11588
|
-
applyHistResumeGate(resumeBtn, screen.dataset.pauseReason || '', budgetState.budget);
|
|
11768
|
+
applyHistResumeGate(resumeBtn, screen.dataset.pauseReason || '', budgetState.budget, screen.dataset.pauseDetail || '');
|
|
11589
11769
|
resumeBtn.addEventListener('click', () => {
|
|
11590
11770
|
const r = hdCurrentRecord(record); // never the load-time object
|
|
11591
11771
|
resumePipeline(r, r.projectDir || null, resumeBtn);
|
|
@@ -13486,6 +13666,21 @@ function rdStateCopy(r, stepName) {
|
|
|
13486
13666
|
// line and the banner above the graph never disagree.
|
|
13487
13667
|
if (r.pauseReason === 'cost_pipeline') return 'Paused — pipeline cost limit reached.';
|
|
13488
13668
|
if (r.pauseReason === 'cost_total') return 'Paused — total budget reached.';
|
|
13669
|
+
if (r.pauseReason === 'error') {
|
|
13670
|
+
const why = r.pauseDetail ? `: ${r.pauseDetail}` : '';
|
|
13671
|
+
return `Paused after an error${why}. Fix the cause, then Resume — the worktree and progress are kept.`;
|
|
13672
|
+
}
|
|
13673
|
+
if (r.pauseReason === 'recoverable') {
|
|
13674
|
+
const why = r.pauseDetail ? ` (${r.pauseDetail})` : '';
|
|
13675
|
+
return `Paused on a recoverable error${why}. Once it clears, Resume retries the step — the worktree and progress are kept.`;
|
|
13676
|
+
}
|
|
13677
|
+
if (r.pauseReason === 'usage_limit') {
|
|
13678
|
+
return `Paused — session/usage limit reached${r.pauseDetail ? ` (${r.pauseDetail})` : ''}. Resume after the reset.`;
|
|
13679
|
+
}
|
|
13680
|
+
if (r.pauseReason && (r.status === 'paused' || r.status === 'pausing' || r.status === 'interrupted')) {
|
|
13681
|
+
// A legacy reason is the orchestrator's own text (a pre-policy session/usage-limit line).
|
|
13682
|
+
return `Paused — ${r.pauseReason}. Resume once it clears.`;
|
|
13683
|
+
}
|
|
13489
13684
|
if (r.status === 'paused' || r.status === 'pausing' || r.status === 'interrupted') {
|
|
13490
13685
|
return 'Paused by you. Agents in flight finished their checkpoint; nothing new is dispatched.';
|
|
13491
13686
|
}
|
|
@@ -14274,10 +14469,14 @@ function statusPill(r) {
|
|
|
14274
14469
|
// A cost pause names its cause so the pill alone explains why the run parked.
|
|
14275
14470
|
if (r.pauseReason === 'cost_pipeline') return { family: 'amber', text: 'Paused · cost limit' };
|
|
14276
14471
|
if (r.pauseReason === 'cost_total') return { family: 'amber', text: 'Paused · total budget' };
|
|
14472
|
+
// An error pause is parked and resumable (never dead), so it stays in the amber family.
|
|
14473
|
+
if (r.pauseReason === 'error') return { family: 'amber', text: 'Paused · error' };
|
|
14474
|
+
if (r.pauseReason === 'recoverable') return { family: 'amber', text: 'Paused · recoverable' };
|
|
14475
|
+
if (r.pauseReason === 'usage_limit') return { family: 'amber', text: 'Paused · usage limit' };
|
|
14277
14476
|
return { family: 'amber', text: 'Paused' };
|
|
14278
14477
|
}
|
|
14279
14478
|
// Same family as `paused`: an interrupted run is parked and resumable, and
|
|
14280
|
-
// PAUSED_STATUSES (app.js:
|
|
14479
|
+
// PAUSED_STATUSES (app.js:10286) already treats it that way.
|
|
14281
14480
|
if (r.status === 'interrupted') return { family: 'amber', text: 'Interrupted' };
|
|
14282
14481
|
if (r.pendingQuestion != null) return { family: 'amber', text: 'Paused · awaiting answers' };
|
|
14283
14482
|
if (r.status === 'starting') return { family: 'peach', text: 'Starting' };
|
|
@@ -14857,9 +15056,16 @@ function paintRunCard(r) {
|
|
|
14857
15056
|
const totalBlocked = r.pauseReason === 'cost_total' && budgetState.budget?.blocked;
|
|
14858
15057
|
if (resumeBtn) {
|
|
14859
15058
|
resumeBtn.disabled = !!totalBlocked;
|
|
14860
|
-
|
|
14861
|
-
|
|
14862
|
-
|
|
15059
|
+
if (totalBlocked) {
|
|
15060
|
+
resumeBtn.title = `Total budget reached — blocked until ${fmtResetAtLocal(budgetState.budget.windowEndMs)} or a higher total limit`;
|
|
15061
|
+
} else if (r.pauseReason === 'error' || r.pauseReason === 'recoverable') {
|
|
15062
|
+
// The cause the run parked on, so the card alone explains what to fix.
|
|
15063
|
+
resumeBtn.title = r.pauseReason === 'recoverable'
|
|
15064
|
+
? `Paused on a recoverable error${r.pauseDetail ? `: ${r.pauseDetail}` : ''} — resume once it clears`
|
|
15065
|
+
: `Paused after an error${r.pauseDetail ? `: ${r.pauseDetail}` : ''} — fix the cause, then resume`;
|
|
15066
|
+
} else {
|
|
15067
|
+
resumeBtn.title = stockResumeTitle();
|
|
15068
|
+
}
|
|
14863
15069
|
}
|
|
14864
15070
|
}
|
|
14865
15071
|
|
|
@@ -15273,6 +15479,19 @@ function paintRdBanners(screen, r) {
|
|
|
15273
15479
|
banners.prepend(fresh); // above the retained-work banner
|
|
15274
15480
|
}
|
|
15275
15481
|
|
|
15482
|
+
// ---- error-pause banner (D11) ----
|
|
15483
|
+
// Same conditional-rebuild shape as paintHdBanners': keyed on the detail so a
|
|
15484
|
+
// repaint neither stacks a second banner nor freezes a corrected cause.
|
|
15485
|
+
const errPaused = isPaused(r) && (r.pauseReason === 'error' || r.pauseReason === 'recoverable');
|
|
15486
|
+
const errDetail = r.pauseDetail || '';
|
|
15487
|
+
const oldErr = banners.querySelector('.pause-error-banner');
|
|
15488
|
+
if (oldErr && (!errPaused || oldErr.dataset.detail !== errDetail)) oldErr.remove();
|
|
15489
|
+
if (errPaused && !banners.querySelector('.pause-error-banner')) {
|
|
15490
|
+
const errBanner = renderErrorPauseBanner(errDetail);
|
|
15491
|
+
errBanner.dataset.detail = errDetail;
|
|
15492
|
+
banners.prepend(errBanner);
|
|
15493
|
+
}
|
|
15494
|
+
|
|
15276
15495
|
// ---- retained work (D11) ----
|
|
15277
15496
|
// renderRetainedWork only READS `p.retainedWork`, so a derived carrier is fine
|
|
15278
15497
|
// for the paint; every MUTATING helper below gets the same carrier so the
|
|
@@ -15319,11 +15538,12 @@ el.runDetail?.addEventListener('click', (e) => {
|
|
|
15319
15538
|
if (override) { confirmCostOverride(r.runId, override); return; } // async, fire-and-forget
|
|
15320
15539
|
if (e.target.closest && e.target.closest('.cb-settings')) { location.hash = 'settings'; return; }
|
|
15321
15540
|
const qbtn = e.target.closest && e.target.closest(
|
|
15322
|
-
'.qpanel .btn-go, .qpanel .gate-continue, .qpanel .gate-another, .qpanel .recovery-retry, .qpanel .recovery-abort');
|
|
15541
|
+
'.qpanel .btn-go, .qpanel .gate-continue, .qpanel .gate-another, .qpanel .recovery-retry, .qpanel .recovery-pause, .qpanel .recovery-abort');
|
|
15323
15542
|
if (!qbtn) return;
|
|
15324
15543
|
if (qbtn.classList.contains('gate-continue')) postAnswer(r, { decision: 'continue' });
|
|
15325
15544
|
else if (qbtn.classList.contains('gate-another')) postAnswer(r, { decision: 'another' });
|
|
15326
15545
|
else if (qbtn.classList.contains('recovery-retry')) postAnswer(r, { decision: 'retry' });
|
|
15546
|
+
else if (qbtn.classList.contains('recovery-pause')) postAnswer(r, { decision: 'pause' });
|
|
15327
15547
|
else if (qbtn.classList.contains('recovery-abort')) postAnswer(r, { decision: 'abort' });
|
|
15328
15548
|
else submitAnswer(r, qbtn.closest('.qpanel'));
|
|
15329
15549
|
});
|
package/ui/public/ask-model.mjs
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Frame classes (spec §6.6 / the P2→P3 contract): job frames carry
|
|
8
8
|
// {threadId, messageId, seq} and are deduped by the per-job monotonic seq;
|
|
9
|
-
// out-of-turn frames (ask-message / ask-title / ask-run-status
|
|
10
|
-
// own key. A seq gap is REPORTED ({gap:true}), never healed here — the panel
|
|
9
|
+
// out-of-turn frames (ask-message / ask-title / ask-run-status / ask-worktrees)
|
|
10
|
+
// upsert by their own key. A seq gap is REPORTED ({gap:true}), never healed here — the panel
|
|
11
11
|
// re-fetches the thread over REST and resubscribes (spec §10.8).
|
|
12
12
|
|
|
13
13
|
const TERMINAL = new Set(['done', 'stopped', 'error']);
|
|
@@ -20,10 +20,11 @@ export function createThreadModel({ threadId }) {
|
|
|
20
20
|
let live = null;
|
|
21
21
|
let inFlight = null;
|
|
22
22
|
let dirty = newDirty();
|
|
23
|
+
let worktrees = []; // P4 §10: the chat's open worktrees (snapshot + ask-worktrees frames); the panel mirrors it
|
|
23
24
|
|
|
24
25
|
function newDirty() {
|
|
25
26
|
// runLinks dirt is produced but not yet consumed — no v1 UI renders run links directly; the follower notices carry the visible state.
|
|
26
|
-
return { structure: false, messages: new Set(), blocks: new Map(), answer: new Set(), label: false, meters: false, title: false, runLinks: false };
|
|
27
|
+
return { structure: false, messages: new Set(), blocks: new Map(), answer: new Set(), label: false, meters: false, title: false, runLinks: false, worktrees: false };
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
function rowById(id) {
|
|
@@ -31,6 +32,17 @@ export function createThreadModel({ threadId }) {
|
|
|
31
32
|
return null;
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
// Agent blocks on the LIVE row, unique by id (upsertBlock replaces in place).
|
|
36
|
+
// Finished turns are already inside thread.totals.agents; ask-done replaces
|
|
37
|
+
// those totals and nulls `live` in ONE frame, so the two never overlap.
|
|
38
|
+
function liveAgentCount() {
|
|
39
|
+
const row = live ? rowById(live.messageId) : null;
|
|
40
|
+
if (!row || !Array.isArray(row.blocks)) return 0;
|
|
41
|
+
let n = 0;
|
|
42
|
+
for (const b of row.blocks) if (b && b.kind === 'agent') n += 1;
|
|
43
|
+
return n;
|
|
44
|
+
}
|
|
45
|
+
|
|
34
46
|
function upsertRow(message) {
|
|
35
47
|
const i = rows.findIndex((r) => r && r.id === message.id);
|
|
36
48
|
if (i >= 0) {
|
|
@@ -52,6 +64,19 @@ export function createThreadModel({ threadId }) {
|
|
|
52
64
|
dirty.structure = true;
|
|
53
65
|
}
|
|
54
66
|
|
|
67
|
+
// The thread's attachment ledger (attachmentsBytes → the composer's budget
|
|
68
|
+
// pre-check) is seeded by the snapshot; without this it would never learn of
|
|
69
|
+
// an upload made in this session, and the composer would let a whole over-
|
|
70
|
+
// budget base64 POST through to the server's 413. Keyed by the store id, so a
|
|
71
|
+
// row seen through both the broadcast and the local echo counts once.
|
|
72
|
+
function noteAttachmentBlocks(blocks) {
|
|
73
|
+
for (const b of Array.isArray(blocks) ? blocks : []) {
|
|
74
|
+
if (!b || b.kind !== 'attachment' || typeof b.id !== 'string') continue;
|
|
75
|
+
if (attachments.some((a) => a && a.id === b.id)) continue;
|
|
76
|
+
attachments.push({ id: b.id, name: b.name, bytes: Number.isFinite(b.bytes) ? b.bytes : 0, kind: b.attKind ?? 'text', mime: b.mime ?? null });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
55
80
|
function markBlockDirty(messageId, blockId) {
|
|
56
81
|
if (!dirty.blocks.has(messageId)) dirty.blocks.set(messageId, new Set());
|
|
57
82
|
dirty.blocks.get(messageId).add(blockId);
|
|
@@ -118,14 +143,14 @@ export function createThreadModel({ threadId }) {
|
|
|
118
143
|
}
|
|
119
144
|
} else if (frame.type === 'ask-start') {
|
|
120
145
|
ensureStreamingRow(frame.messageId, frame);
|
|
121
|
-
live = { messageId: frame.messageId, userMessageId: frame.userMessageId ?? null, label: 'Thinking', startedAt: frame.startedAt ?? null, lastSeq: frame.seq, text: '', usage: null, costUsd: null };
|
|
146
|
+
live = { messageId: frame.messageId, userMessageId: frame.userMessageId ?? null, label: 'Thinking', startedAt: frame.startedAt ?? null, lastSeq: frame.seq, text: '', usage: null, costUsd: null, estimatedCostUsd: null };
|
|
122
147
|
inFlight = { messageId: frame.messageId };
|
|
123
148
|
dirty.label = true;
|
|
124
149
|
} else if (!live && inFlight && frame.messageId === inFlight.messageId) {
|
|
125
150
|
// Adoption: the ring buffer may have evicted the prefix — accept the first
|
|
126
151
|
// frame at whatever seq it carries; ask-done.text heals the missing text.
|
|
127
152
|
ensureStreamingRow(frame.messageId);
|
|
128
|
-
live = { messageId: frame.messageId, userMessageId: null, label: null, startedAt: null, lastSeq: frame.seq, text: '', usage: null, costUsd: null, adopted: true };
|
|
153
|
+
live = { messageId: frame.messageId, userMessageId: null, label: null, startedAt: null, lastSeq: frame.seq, text: '', usage: null, costUsd: null, estimatedCostUsd: null, adopted: true };
|
|
129
154
|
} else {
|
|
130
155
|
return { dropped: 'no-live' };
|
|
131
156
|
}
|
|
@@ -149,11 +174,13 @@ export function createThreadModel({ threadId }) {
|
|
|
149
174
|
if (frame.block && frame.block.id != null) {
|
|
150
175
|
upsertBlock(row, frame.block);
|
|
151
176
|
markBlockDirty(frame.messageId, frame.block.id);
|
|
177
|
+
if (frame.type === 'ask-block' && frame.block.kind === 'agent') dirty.meters = true; // "N agents" moves live
|
|
152
178
|
}
|
|
153
179
|
break;
|
|
154
180
|
case 'ask-usage':
|
|
155
181
|
live.usage = frame.usage ?? null;
|
|
156
182
|
live.costUsd = frame.costUsd ?? null;
|
|
183
|
+
live.estimatedCostUsd = Number.isFinite(frame.estimatedCostUsd) ? frame.estimatedCostUsd : null; // display-only
|
|
157
184
|
dirty.meters = true;
|
|
158
185
|
break;
|
|
159
186
|
case 'ask-done':
|
|
@@ -187,6 +214,7 @@ export function createThreadModel({ threadId }) {
|
|
|
187
214
|
const m = frame.message;
|
|
188
215
|
if (!m || typeof m.id !== 'string') return { dropped: 'no-live' };
|
|
189
216
|
upsertRow(m);
|
|
217
|
+
noteAttachmentBlocks(m.blocks);
|
|
190
218
|
dirty.messages.add(m.id);
|
|
191
219
|
return { ok: true };
|
|
192
220
|
}
|
|
@@ -202,6 +230,11 @@ export function createThreadModel({ threadId }) {
|
|
|
202
230
|
dirty.runLinks = true;
|
|
203
231
|
return { ok: true };
|
|
204
232
|
}
|
|
233
|
+
case 'ask-worktrees':
|
|
234
|
+
if (!Array.isArray(frame.worktrees)) return { dropped: 'no-live' };
|
|
235
|
+
worktrees = frame.worktrees.slice();
|
|
236
|
+
dirty.worktrees = true;
|
|
237
|
+
return { ok: true };
|
|
205
238
|
default:
|
|
206
239
|
return { dropped: 'no-live' };
|
|
207
240
|
}
|
|
@@ -217,6 +250,7 @@ export function createThreadModel({ threadId }) {
|
|
|
217
250
|
for (const l of Array.isArray(snapshot.runLinks) ? snapshot.runLinks : []) {
|
|
218
251
|
links.set(l.runId, { pipelineId: l.pipelineId ?? null, cardId: l.cardId ?? null, status: l.status ?? null, phase: l.phase ?? null });
|
|
219
252
|
}
|
|
253
|
+
worktrees = Array.isArray(snapshot.worktrees) ? snapshot.worktrees.slice() : []; // the fresh-thread load carries no key
|
|
220
254
|
live = null;
|
|
221
255
|
inFlight = snapshot.inFlight ?? null;
|
|
222
256
|
dirty = newDirty();
|
|
@@ -225,6 +259,7 @@ export function createThreadModel({ threadId }) {
|
|
|
225
259
|
dirty.meters = true;
|
|
226
260
|
dirty.runLinks = true;
|
|
227
261
|
dirty.label = true;
|
|
262
|
+
dirty.worktrees = true;
|
|
228
263
|
},
|
|
229
264
|
apply(frame) {
|
|
230
265
|
if (!frame || frame.threadId !== threadId) return { dropped: 'other-thread' };
|
|
@@ -239,11 +274,22 @@ export function createThreadModel({ threadId }) {
|
|
|
239
274
|
messages() { return rows; },
|
|
240
275
|
thread() { return thread; },
|
|
241
276
|
totals() {
|
|
242
|
-
|
|
277
|
+
const base = thread.totals && typeof thread.totals === 'object' ? thread.totals : {};
|
|
278
|
+
const liveAgents = live ? liveAgentCount() : 0;
|
|
279
|
+
return {
|
|
280
|
+
...base,
|
|
281
|
+
...(liveAgents ? { agents: (Number.isFinite(base.agents) ? base.agents : 0) + liveAgents } : {}),
|
|
282
|
+
live: live ? { usage: live.usage, costUsd: live.costUsd, estimatedCostUsd: live.estimatedCostUsd } : null,
|
|
283
|
+
};
|
|
243
284
|
},
|
|
244
285
|
inFlight() { return inFlight; },
|
|
245
286
|
live() { return live; },
|
|
246
287
|
runLinks() { return links; },
|
|
288
|
+
worktrees() { return worktrees; },
|
|
289
|
+
setWorktrees(list) { // the panel's heal (refreshWorktrees) feeds the same store the frames do
|
|
290
|
+
worktrees = Array.isArray(list) ? list.slice() : [];
|
|
291
|
+
dirty.worktrees = true;
|
|
292
|
+
},
|
|
247
293
|
attachmentsBytes() { return attachments.reduce((n, a) => n + (a && Number.isFinite(a.bytes) ? a.bytes : 0), 0); },
|
|
248
294
|
findCard(cardId) {
|
|
249
295
|
for (const r of rows) {
|
|
@@ -253,11 +299,18 @@ export function createThreadModel({ threadId }) {
|
|
|
253
299
|
return null;
|
|
254
300
|
},
|
|
255
301
|
noteLocalUserMessage({ id, text, attachments: atts }) {
|
|
302
|
+
// The POST-side ask-message broadcast can land BEFORE the 202 resolves. That
|
|
303
|
+
// row is the persisted one (seq, store-minted attachment ids); an echo that
|
|
304
|
+
// replaced it would turn an image thumbnail back into a name pill until the
|
|
305
|
+
// next reload. Nothing the echo carries is newer than it, so keep it.
|
|
306
|
+
if (rowById(id)) { dirty.messages.add(id); return; }
|
|
307
|
+
const blocks = (Array.isArray(atts) ? atts : []).map((a) => ({ kind: 'attachment', id: a.id ?? null, name: a.name, bytes: a.bytes, attKind: a.attKind ?? 'text', mime: a.mime ?? null }));
|
|
256
308
|
upsertRow({
|
|
257
309
|
id, threadId, seq: undefined, role: 'user', text: String(text ?? ''),
|
|
258
|
-
blocks
|
|
310
|
+
blocks,
|
|
259
311
|
status: null, reason: null, model: null, effort: null, usage: null, costUsd: null, durationMs: null, createdAt: null,
|
|
260
312
|
});
|
|
313
|
+
noteAttachmentBlocks(blocks);
|
|
261
314
|
dirty.messages.add(id);
|
|
262
315
|
},
|
|
263
316
|
});
|