claude-code-kanban 4.19.0 → 4.21.0
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 +10 -1
- package/lib/approvals.js +82 -0
- package/lib/session-events.js +99 -0
- package/package.json +2 -2
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +5 -2
- package/plugin/plugins/claude-code-kanban/hooks/hooks.json +2 -24
- package/plugin/plugins/claude-code-kanban/monitors.json +8 -0
- package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +17 -3
- package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +141 -0
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +67 -0
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +20 -29
- package/plugin/plugins/claude-code-kanban/skills/kanban-follow/SKILL.md +39 -0
- package/public/app.js +582 -90
- package/public/index.html +2 -1
- package/public/style.css +359 -5
- package/server.js +157 -26
package/public/app.js
CHANGED
|
@@ -567,8 +567,6 @@ async function refreshProjectAgents() {
|
|
|
567
567
|
renderAgentFooter();
|
|
568
568
|
}
|
|
569
569
|
|
|
570
|
-
//#endregion
|
|
571
|
-
|
|
572
570
|
//#region MESSAGE_PANEL
|
|
573
571
|
function toggleMessagePanel() {
|
|
574
572
|
const panel = document.getElementById('message-panel');
|
|
@@ -1180,18 +1178,19 @@ function toggleToolGroup(id) {
|
|
|
1180
1178
|
if (el) el.classList.toggle('show');
|
|
1181
1179
|
}
|
|
1182
1180
|
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
if (tool === 'ExitPlanMode') return 'Plan awaiting approval';
|
|
1186
|
-
return 'Question pending';
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1181
|
+
// Legacy markers (written before the gate minted kind:"plan") carry no kind
|
|
1182
|
+
// for plans — the tool name is the durable signal there.
|
|
1189
1183
|
function getWaitingPill(kind, tool) {
|
|
1190
|
-
if (kind === '
|
|
1184
|
+
if (kind === 'plan' || tool === 'ExitPlanMode') return 'Plan awaiting approval';
|
|
1191
1185
|
if (kind === 'question') return 'Question pending';
|
|
1192
1186
|
return 'Awaiting permission';
|
|
1193
1187
|
}
|
|
1194
1188
|
|
|
1189
|
+
function getWaitingLabel(kind, tool) {
|
|
1190
|
+
const pill = getWaitingPill(kind, tool);
|
|
1191
|
+
return pill === 'Awaiting permission' ? `${pill}: ${tool}` : pill;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1195
1194
|
function deriveWaitingDetail(tool, params) {
|
|
1196
1195
|
if (!params) return '';
|
|
1197
1196
|
const trunc = (s) => (s.length > 80 ? `${s.slice(0, 80)}...` : s);
|
|
@@ -1235,17 +1234,40 @@ function renderWaitingBody(tool, params) {
|
|
|
1235
1234
|
return renderToolParamsHtml(params);
|
|
1236
1235
|
}
|
|
1237
1236
|
|
|
1237
|
+
const PLAN_REJECT_MSG = 'Plan rejected from the kanban board.';
|
|
1238
|
+
|
|
1239
|
+
// Answerable only when the marker carries a request id (written by
|
|
1240
|
+
// approval-gate.sh, which is polling for a decision) and the gate hasn't
|
|
1241
|
+
// lapsed past its wait window — older or lapsed markers are only answerable
|
|
1242
|
+
// in the terminal.
|
|
1243
|
+
function isWaitingAnswerable() {
|
|
1244
|
+
return !!(currentWaiting?.id && !currentWaiting.lapsed);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function parseWaitingInput() {
|
|
1248
|
+
try {
|
|
1249
|
+
return JSON.parse(currentWaiting?.toolInput || '');
|
|
1250
|
+
} catch (_) {
|
|
1251
|
+
return null; /* toolInput may be truncated/non-JSON */
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// One source for the decision-button pair on every surface — Approve/Reject
|
|
1256
|
+
// for plans, Allow/Deny for permissions; only the css class prefix differs.
|
|
1257
|
+
// Questions return '' (answered via the form in the detail modal).
|
|
1258
|
+
function waitingDecisionButtons(cls) {
|
|
1259
|
+
if (!isWaitingAnswerable() || currentWaiting.kind === 'question') return '';
|
|
1260
|
+
const plan = currentWaiting.kind === 'plan';
|
|
1261
|
+
// Code-authored call argument, not data — hence the constant-style name the
|
|
1262
|
+
// escaping check reads as safe.
|
|
1263
|
+
const DENY_ARG = plan ? "{behavior:'deny',message:PLAN_REJECT_MSG}" : "{behavior:'deny'}";
|
|
1264
|
+
return `<button class="${escapeHtml(cls)}-allow" onclick="event.stopPropagation();respondWaiting({behavior:'allow'})">${plan ? 'Approve' : 'Allow'}</button><button class="${escapeHtml(cls)}-deny" onclick="event.stopPropagation();respondWaiting(${DENY_ARG})">${plan ? 'Reject' : 'Deny'}</button>`;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1238
1267
|
function renderWaitingEntry() {
|
|
1239
1268
|
if (!isWaitingFresh()) return '';
|
|
1240
1269
|
const tool = currentWaiting.toolName || 'unknown';
|
|
1241
|
-
|
|
1242
|
-
if (currentWaiting.toolInput) {
|
|
1243
|
-
try {
|
|
1244
|
-
params = JSON.parse(currentWaiting.toolInput);
|
|
1245
|
-
} catch (_) {
|
|
1246
|
-
/* toolInput may be truncated/non-JSON */
|
|
1247
|
-
}
|
|
1248
|
-
}
|
|
1270
|
+
const params = parseWaitingInput();
|
|
1249
1271
|
const pillText = getWaitingPill(currentWaiting.kind, tool);
|
|
1250
1272
|
const detail = deriveWaitingDetail(tool, params);
|
|
1251
1273
|
const detailHtml = detail ? ` <span style="color:var(--text-secondary)">${escapeHtml(detail)}</span>` : '';
|
|
@@ -1253,7 +1275,73 @@ function renderWaitingEntry() {
|
|
|
1253
1275
|
const bodyWrap = bodyHtml ? `<div class="msg-waiting-body">${bodyHtml}</div>` : '';
|
|
1254
1276
|
const pill = `<span class="msg-waiting-pill">${escapeHtml(pillText)}</span>`;
|
|
1255
1277
|
const discardBtn = `<button class="msg-waiting-discard" title="Discard permission prompt" onclick="event.stopPropagation();discardWaiting()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>`;
|
|
1256
|
-
|
|
1278
|
+
// Questions are answered in the detail modal; plans render there too.
|
|
1279
|
+
const buttons = waitingDecisionButtons('msg-waiting');
|
|
1280
|
+
const actions = buttons ? `<span class="msg-waiting-actions">${buttons}</span>` : '';
|
|
1281
|
+
// Lapsed = gate stopped polling — the ask is only answerable in the terminal
|
|
1282
|
+
const lapsed = !!currentWaiting.lapsed;
|
|
1283
|
+
return `<div class="msg-item msg-waiting" onclick="msgDetailFollowLatest=false;showWaitingDetail()">${getToolIcon(tool)}<div class="msg-body"><div class="msg-text">${pill} <span style="font-weight:600">${escapeHtml(tool)}</span>${detailHtml}</div>${bodyWrap}<div class="msg-waiting-footer"><div class="msg-time">${lapsed ? 'answer in the terminal' : 'waiting…'}</div>${actions}</div></div>${discardBtn}</div>`;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// Shared approval controls for a fresh plan ask — the waiting-detail footer and
|
|
1287
|
+
// the saved-plan modal offer the same decision, so they render the same row.
|
|
1288
|
+
// Distinct input ids keep the two surfaces from shadowing each other's feedback.
|
|
1289
|
+
function planApprovalControlsHtml(inputId) {
|
|
1290
|
+
return `<input id="${escapeHtml(inputId)}" class="waiting-option-input" type="text" placeholder="feedback for reject (optional)…"><button class="waiting-btn-allow" onclick="respondWaiting({behavior:'allow'})">Approve</button><button class="waiting-btn-deny" onclick="rejectWaitingPlan('${escAttrJs(inputId)}')">Reject</button>`;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
|
|
1294
|
+
function rejectWaitingPlan(inputId) {
|
|
1295
|
+
const feedback = document.getElementById(inputId)?.value?.trim();
|
|
1296
|
+
respondWaiting({ behavior: 'deny', message: feedback || PLAN_REJECT_MSG });
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// UI-driven approvals: POST the decision the blocking approval-gate.sh hook is
|
|
1300
|
+
// polling for. First writer wins (D5) — a 409/410 means the ask was answered in
|
|
1301
|
+
// the terminal or superseded, so just drop the card.
|
|
1302
|
+
async function respondWaiting(payload) {
|
|
1303
|
+
if (!currentSessionId || !currentWaiting?.id) return;
|
|
1304
|
+
try {
|
|
1305
|
+
const res = await fetch(`/api/sessions/${encodeURIComponent(currentSessionId)}/waiting/respond`, {
|
|
1306
|
+
method: 'POST',
|
|
1307
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1308
|
+
body: JSON.stringify({ id: currentWaiting.id, ...payload }),
|
|
1309
|
+
});
|
|
1310
|
+
if (res.ok || res.status === 409 || res.status === 410) {
|
|
1311
|
+
clearWaitingUi();
|
|
1312
|
+
} else {
|
|
1313
|
+
// Surface a respond failure — a silent console error reads as a dead button
|
|
1314
|
+
const text = await res.text();
|
|
1315
|
+
console.error('[respondWaiting]', res.status, text);
|
|
1316
|
+
let msg = text;
|
|
1317
|
+
try {
|
|
1318
|
+
msg = JSON.parse(text).error || text;
|
|
1319
|
+
} catch (_) {
|
|
1320
|
+
/* plain text */
|
|
1321
|
+
}
|
|
1322
|
+
showToast(msg, 'error');
|
|
1323
|
+
}
|
|
1324
|
+
} catch (e) {
|
|
1325
|
+
console.error('[respondWaiting]', e);
|
|
1326
|
+
showToast(String(e), 'error');
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// Drop the answered/discarded ask from every surface it renders on
|
|
1331
|
+
function clearWaitingUi() {
|
|
1332
|
+
currentWaiting = null;
|
|
1333
|
+
waitingAnswerDraft = {};
|
|
1334
|
+
waitingCustomDraft = {};
|
|
1335
|
+
if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
|
|
1336
|
+
// In follow mode stay in the modal and swap to the latest message —
|
|
1337
|
+
// closing it would silently drop the user out of following.
|
|
1338
|
+
if (msgDetailFollowLatest && currentMessages.length) showMsgDetail(currentMessages.length - 1);
|
|
1339
|
+
else closeMsgDetailModal();
|
|
1340
|
+
}
|
|
1341
|
+
// The saved-plan modal stays open after a decision — only its approval row goes
|
|
1342
|
+
document.getElementById('plan-approval-footer')?.remove();
|
|
1343
|
+
renderMessages(currentMessages);
|
|
1344
|
+
renderAgentFooter();
|
|
1257
1345
|
}
|
|
1258
1346
|
|
|
1259
1347
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
|
|
@@ -1263,12 +1351,7 @@ async function discardWaiting() {
|
|
|
1263
1351
|
const res = await fetch(`/api/sessions/${encodeURIComponent(currentSessionId)}/waiting/discard`, {
|
|
1264
1352
|
method: 'POST',
|
|
1265
1353
|
});
|
|
1266
|
-
if (res.ok)
|
|
1267
|
-
currentWaiting = null;
|
|
1268
|
-
if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) closeMsgDetailModal();
|
|
1269
|
-
renderMessages(currentMessages);
|
|
1270
|
-
renderAgentFooter();
|
|
1271
|
-
}
|
|
1354
|
+
if (res.ok) clearWaitingUi();
|
|
1272
1355
|
} catch (e) {
|
|
1273
1356
|
console.error('[discardWaiting]', e);
|
|
1274
1357
|
}
|
|
@@ -1425,6 +1508,7 @@ function toggleAgentPin(agentId) {
|
|
|
1425
1508
|
}
|
|
1426
1509
|
savePins(currentSessionId, currentPins);
|
|
1427
1510
|
renderMessages(currentMessages);
|
|
1511
|
+
renderSessions();
|
|
1428
1512
|
renderAgentFooter();
|
|
1429
1513
|
}
|
|
1430
1514
|
|
|
@@ -1476,6 +1560,7 @@ function togglePin(msgIndex) {
|
|
|
1476
1560
|
}
|
|
1477
1561
|
savePins(currentSessionId, currentPins);
|
|
1478
1562
|
renderMessages(currentMessages);
|
|
1563
|
+
renderSessions();
|
|
1479
1564
|
updateMsgDetailPinState();
|
|
1480
1565
|
}
|
|
1481
1566
|
|
|
@@ -1485,6 +1570,7 @@ function unpinById(pinIdx) {
|
|
|
1485
1570
|
currentPins.splice(pinIdx, 1);
|
|
1486
1571
|
savePins(currentSessionId, currentPins);
|
|
1487
1572
|
renderMessages(currentMessages);
|
|
1573
|
+
renderSessions();
|
|
1488
1574
|
if (wasAgent) renderAgentFooter();
|
|
1489
1575
|
updateMsgDetailPinState();
|
|
1490
1576
|
}
|
|
@@ -1759,6 +1845,7 @@ function highlightSelectedMsg() {
|
|
|
1759
1845
|
|
|
1760
1846
|
function showMsgDetail(idx) {
|
|
1761
1847
|
currentMsgDetailIdx = idx;
|
|
1848
|
+
document.getElementById('msg-detail-waiting-footer').innerHTML = '';
|
|
1762
1849
|
msgHighlightDimmed = false;
|
|
1763
1850
|
const m = currentMessages[idx];
|
|
1764
1851
|
if (!m) return;
|
|
@@ -1900,8 +1987,22 @@ function _setModalWidth(modal, slot, on, maxWidth, width) {
|
|
|
1900
1987
|
}
|
|
1901
1988
|
}
|
|
1902
1989
|
|
|
1990
|
+
function _modalEl(modalId) {
|
|
1991
|
+
return document.querySelector(`#${modalId} .modal`);
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
// The opt-in marker for fullscreen and drag-resize alike: the presence of a
|
|
1995
|
+
// `<modalId>-fullscreen-btn` button in the markup.
|
|
1996
|
+
function _forEachFullscreenModal(cb) {
|
|
1997
|
+
for (const btn of document.querySelectorAll('[id$="-fullscreen-btn"]')) {
|
|
1998
|
+
const modalId = btn.id.replace(/-fullscreen-btn$/, '');
|
|
1999
|
+
const modal = _modalEl(modalId);
|
|
2000
|
+
if (modal) cb(modalId, modal);
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
|
|
1903
2004
|
function _applyModalFullscreen(modalId, on) {
|
|
1904
|
-
const modal =
|
|
2005
|
+
const modal = _modalEl(modalId);
|
|
1905
2006
|
modal.classList.toggle('fullscreen', on);
|
|
1906
2007
|
_setModalWidth(modal, 'Fs', on, '', '');
|
|
1907
2008
|
updateFullscreenBtnIcon(`${modalId}-fullscreen-btn`, on);
|
|
@@ -1909,16 +2010,15 @@ function _applyModalFullscreen(modalId, on) {
|
|
|
1909
2010
|
|
|
1910
2011
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
1911
2012
|
function toggleModalFullscreen(modalId) {
|
|
1912
|
-
const on = !
|
|
2013
|
+
const on = !_modalEl(modalId).classList.contains('fullscreen');
|
|
1913
2014
|
_applyModalFullscreen(modalId, on);
|
|
1914
2015
|
localStorage.setItem(`modal-fullscreen-${modalId}`, String(on));
|
|
1915
2016
|
}
|
|
1916
2017
|
|
|
1917
2018
|
function loadModalFullscreen() {
|
|
1918
|
-
|
|
1919
|
-
const modalId = btn.id.replace(/-fullscreen-btn$/, '');
|
|
2019
|
+
_forEachFullscreenModal((modalId) => {
|
|
1920
2020
|
if (localStorage.getItem(`modal-fullscreen-${modalId}`) === 'true') _applyModalFullscreen(modalId, true);
|
|
1921
|
-
}
|
|
2021
|
+
});
|
|
1922
2022
|
}
|
|
1923
2023
|
|
|
1924
2024
|
// Hides the overlay only — the fullscreen state stays on the dialog, it is a
|
|
@@ -1937,6 +2037,72 @@ function updateFullscreenBtnIcon(btnId, isFullscreen) {
|
|
|
1937
2037
|
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
|
|
1938
2038
|
}
|
|
1939
2039
|
|
|
2040
|
+
// Free-form modal resize: drag the bottom-right grip, double-click it to reset.
|
|
2041
|
+
// The size lives in CSS custom properties + `.user-sized` (see style.css) rather
|
|
2042
|
+
// than inline width/height, so it never collides with the inline-style stash
|
|
2043
|
+
// that fullscreen and "Show more" do via _setModalWidth. The viewport cap lives
|
|
2044
|
+
// only in the CSS min(); JS clamps just the minimums.
|
|
2045
|
+
const MODAL_MIN_W = 360;
|
|
2046
|
+
const MODAL_MIN_H = 240;
|
|
2047
|
+
|
|
2048
|
+
function _setModalUserSize(modal, w, h) {
|
|
2049
|
+
modal.style.setProperty('--user-modal-w', w);
|
|
2050
|
+
modal.style.setProperty('--user-modal-h', h);
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
function initModalResize() {
|
|
2054
|
+
_forEachFullscreenModal((modalId, modal) => {
|
|
2055
|
+
const wKey = `modal-width-${modalId}`;
|
|
2056
|
+
const hKey = `modal-height-${modalId}`;
|
|
2057
|
+
const savedW = localStorage.getItem(wKey);
|
|
2058
|
+
const savedH = localStorage.getItem(hKey);
|
|
2059
|
+
if (savedW && savedH) {
|
|
2060
|
+
_setModalUserSize(modal, savedW, savedH);
|
|
2061
|
+
modal.classList.add('user-sized');
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
const handle = document.createElement('div');
|
|
2065
|
+
handle.className = 'modal-resize-handle';
|
|
2066
|
+
handle.title = 'Drag to resize · double-click to reset';
|
|
2067
|
+
modal.appendChild(handle);
|
|
2068
|
+
|
|
2069
|
+
let startW, startH, w, h;
|
|
2070
|
+
_initDragResize(handle, {
|
|
2071
|
+
onStart() {
|
|
2072
|
+
startW = modal.offsetWidth;
|
|
2073
|
+
startH = modal.offsetHeight;
|
|
2074
|
+
// autoSizeModal / "Show more" may have widened the dialog inline
|
|
2075
|
+
// before the first drag; inline width beats the .user-sized CSS.
|
|
2076
|
+
modal.style.width = '';
|
|
2077
|
+
modal.style.maxWidth = '';
|
|
2078
|
+
// Vars and class land together, so `.user-sized` never reads an
|
|
2079
|
+
// undefined var.
|
|
2080
|
+
_setModalUserSize(modal, `${startW}px`, `${startH}px`);
|
|
2081
|
+
modal.classList.add('user-sized');
|
|
2082
|
+
},
|
|
2083
|
+
// The overlay centers the modal, so both edges move — double the delta
|
|
2084
|
+
// to keep the grip under the cursor.
|
|
2085
|
+
onMove(dx, dy) {
|
|
2086
|
+
w = Math.max(MODAL_MIN_W, startW + dx * 2);
|
|
2087
|
+
h = Math.max(MODAL_MIN_H, startH + dy * 2);
|
|
2088
|
+
_setModalUserSize(modal, `${w}px`, `${h}px`);
|
|
2089
|
+
},
|
|
2090
|
+
onEnd() {
|
|
2091
|
+
if (w && h) {
|
|
2092
|
+
localStorage.setItem(wKey, `${w}px`);
|
|
2093
|
+
localStorage.setItem(hKey, `${h}px`);
|
|
2094
|
+
}
|
|
2095
|
+
},
|
|
2096
|
+
});
|
|
2097
|
+
|
|
2098
|
+
handle.addEventListener('dblclick', () => {
|
|
2099
|
+
modal.classList.remove('user-sized');
|
|
2100
|
+
localStorage.removeItem(wKey);
|
|
2101
|
+
localStorage.removeItem(hKey);
|
|
2102
|
+
});
|
|
2103
|
+
});
|
|
2104
|
+
}
|
|
2105
|
+
|
|
1940
2106
|
const MODAL_ZOOM_KEY = 'modal-zoom';
|
|
1941
2107
|
const MODAL_ZOOM_MIN = 0.7;
|
|
1942
2108
|
const MODAL_ZOOM_MAX = 2.0;
|
|
@@ -2325,7 +2491,8 @@ function _applyExpandToggle(btn, fullEl) {
|
|
|
2325
2491
|
const panel = btn.closest('.message-panel');
|
|
2326
2492
|
if (panel) panel.classList.toggle('msg-expanded-wide', expand);
|
|
2327
2493
|
const modal = btn.closest('.modal');
|
|
2328
|
-
|
|
2494
|
+
// A user-dragged size is an explicit choice — don't widen over it.
|
|
2495
|
+
if (modal && !modal.classList.contains('user-sized')) _setModalWidth(modal, 'Expand', expand, '60vw', '60vw');
|
|
2329
2496
|
}
|
|
2330
2497
|
function _toggleExpand(btn) {
|
|
2331
2498
|
const f = document.getElementById(btn.dataset.expandId);
|
|
@@ -2343,7 +2510,7 @@ function makeExpandToggle(_truncatedHtml, fullHtml, opts = {}) {
|
|
|
2343
2510
|
}
|
|
2344
2511
|
|
|
2345
2512
|
function autoSizeModal(modal, body) {
|
|
2346
|
-
if (modal.classList.contains('fullscreen')) return;
|
|
2513
|
+
if (modal.classList.contains('fullscreen') || modal.classList.contains('user-sized')) return;
|
|
2347
2514
|
modal.style.maxWidth = '';
|
|
2348
2515
|
modal.classList.remove('has-mermaid');
|
|
2349
2516
|
const hasMermaid = body.querySelector('pre.mermaid') !== null;
|
|
@@ -2547,7 +2714,7 @@ function renderAgentFooter() {
|
|
|
2547
2714
|
document.getElementById('agent-footer-toggle').innerHTML = collapsed ? '▴' : '▾';
|
|
2548
2715
|
|
|
2549
2716
|
const permHtml = permFresh
|
|
2550
|
-
? `<
|
|
2717
|
+
? `<button type="button" class="permission-badge" onclick="showWaitingDetail()">${currentWaiting.kind === 'question' ? '❓ Question pending' : currentWaiting.kind === 'plan' ? '📋 Plan pending' : `⏳ Awaiting: ${escapeHtml(currentWaiting.toolName || 'unknown')}`}</button>`
|
|
2551
2718
|
: '';
|
|
2552
2719
|
|
|
2553
2720
|
content.innerHTML =
|
|
@@ -3285,6 +3452,59 @@ function renderTaskCard(task) {
|
|
|
3285
3452
|
//#endregion
|
|
3286
3453
|
|
|
3287
3454
|
//#region KANBAN
|
|
3455
|
+
// FLIP pass around renderKanban's innerHTML rebuild (#41): the rebuild lands cards at
|
|
3456
|
+
// their final spot instantly, so a status change reads as a full-board reload. Recording
|
|
3457
|
+
// where each card was and animating from there makes a move glide instead.
|
|
3458
|
+
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
3459
|
+
|
|
3460
|
+
function cardFlipKey(el) {
|
|
3461
|
+
return `${el.dataset.sessionId}|${el.dataset.taskId}`;
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
function captureCardRects() {
|
|
3465
|
+
const rects = new Map();
|
|
3466
|
+
for (const el of document.querySelectorAll('.column-tasks .task-card')) {
|
|
3467
|
+
rects.set(cardFlipKey(el), el.getBoundingClientRect());
|
|
3468
|
+
}
|
|
3469
|
+
return rects;
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3472
|
+
function playCardFlip(before) {
|
|
3473
|
+
// An empty board before the render means a view switch, not a move — nothing to glide.
|
|
3474
|
+
if (reducedMotion.matches || before.size === 0) return;
|
|
3475
|
+
for (const el of document.querySelectorAll('.column-tasks .task-card')) {
|
|
3476
|
+
const prev = before.get(cardFlipKey(el));
|
|
3477
|
+
if (!prev) {
|
|
3478
|
+
el.animate(
|
|
3479
|
+
[
|
|
3480
|
+
{ opacity: 0, transform: 'scale(0.97)' },
|
|
3481
|
+
{ opacity: 1, transform: 'none' },
|
|
3482
|
+
],
|
|
3483
|
+
{ duration: 150, easing: 'ease-out' },
|
|
3484
|
+
);
|
|
3485
|
+
continue;
|
|
3486
|
+
}
|
|
3487
|
+
const now = el.getBoundingClientRect();
|
|
3488
|
+
const dx = prev.left - now.left;
|
|
3489
|
+
const dy = prev.top - now.top;
|
|
3490
|
+
if (dx || dy) {
|
|
3491
|
+
el.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], {
|
|
3492
|
+
duration: 220,
|
|
3493
|
+
easing: 'cubic-bezier(0.2, 0.7, 0.3, 1)',
|
|
3494
|
+
});
|
|
3495
|
+
}
|
|
3496
|
+
}
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3499
|
+
// Rewrite a column only when its markup actually changed: SSE ticks fire on any task-file
|
|
3500
|
+
// write (updatedAt bumps, agent refreshes), and an unconditional innerHTML rebuild makes
|
|
3501
|
+
// the whole board blink for updates with nothing visible in them (#41).
|
|
3502
|
+
function setColumnHtml(el, html) {
|
|
3503
|
+
if (el._lastHtml === html) return;
|
|
3504
|
+
el._lastHtml = html;
|
|
3505
|
+
el.innerHTML = html;
|
|
3506
|
+
}
|
|
3507
|
+
|
|
3288
3508
|
function renderKanban() {
|
|
3289
3509
|
let filtered = currentTasks.filter((t) => !isInternalTask(t));
|
|
3290
3510
|
if (ownerFilter) {
|
|
@@ -3299,21 +3519,43 @@ function renderKanban() {
|
|
|
3299
3519
|
completedCount.textContent = completed.length;
|
|
3300
3520
|
|
|
3301
3521
|
const emptyIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>`;
|
|
3522
|
+
const plusIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 5v14M5 12h14"/></svg>`;
|
|
3523
|
+
|
|
3524
|
+
const writes = [];
|
|
3525
|
+
// Adding is a live text input inside the column, so a background refresh would blow it
|
|
3526
|
+
// away mid-typing -- leave the column alone until the input is gone.
|
|
3527
|
+
if (!addingTask) {
|
|
3528
|
+
const addTile = canAddTask()
|
|
3529
|
+
? `<button type="button" class="column-add${pending.length ? '' : ' empty'}" onclick="startAddTask(this)">${plusIcon}<span>Add task</span></button>`
|
|
3530
|
+
: '';
|
|
3531
|
+
writes.push([
|
|
3532
|
+
pendingTasks,
|
|
3533
|
+
pending.length > 0
|
|
3534
|
+
? pending.map(renderTaskCard).join('') + addTile
|
|
3535
|
+
: addTile || `<div class="column-empty">${emptyIcon}<div>No pending tasks</div></div>`,
|
|
3536
|
+
]);
|
|
3537
|
+
}
|
|
3302
3538
|
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
? pending.map(renderTaskCard).join('')
|
|
3306
|
-
: `<div class="column-empty">${emptyIcon}<div>No pending tasks</div></div>`;
|
|
3307
|
-
|
|
3308
|
-
inProgressTasks.innerHTML =
|
|
3539
|
+
writes.push([
|
|
3540
|
+
inProgressTasks,
|
|
3309
3541
|
inProgress.length > 0
|
|
3310
3542
|
? inProgress.map(renderTaskCard).join('')
|
|
3311
|
-
: `<div class="column-empty">${emptyIcon}<div>No active tasks</div></div
|
|
3543
|
+
: `<div class="column-empty">${emptyIcon}<div>No active tasks</div></div>`,
|
|
3544
|
+
]);
|
|
3312
3545
|
|
|
3313
|
-
|
|
3546
|
+
writes.push([
|
|
3547
|
+
completedTasks,
|
|
3314
3548
|
completed.length > 0
|
|
3315
3549
|
? completed.map(renderTaskCard).join('')
|
|
3316
|
-
: `<div class="column-empty">${emptyIcon}<div>No completed tasks</div></div
|
|
3550
|
+
: `<div class="column-empty">${emptyIcon}<div>No completed tasks</div></div>`,
|
|
3551
|
+
]);
|
|
3552
|
+
|
|
3553
|
+
// Capture rects (a forced layout read per card) only when a column will
|
|
3554
|
+
// actually be rewritten — most SSE ticks change nothing and skip the FLIP.
|
|
3555
|
+
const willChange = writes.some(([el, html]) => el._lastHtml !== html);
|
|
3556
|
+
const flipRects = willChange ? captureCardRects() : null;
|
|
3557
|
+
for (const [el, html] of writes) setColumnHtml(el, html);
|
|
3558
|
+
if (flipRects) playCardFlip(flipRects);
|
|
3317
3559
|
|
|
3318
3560
|
if (selectedTaskId) {
|
|
3319
3561
|
const card =
|
|
@@ -3334,6 +3576,81 @@ function renderKanban() {
|
|
|
3334
3576
|
|
|
3335
3577
|
//#endregion
|
|
3336
3578
|
|
|
3579
|
+
//#region ADD_TASK
|
|
3580
|
+
let addingTask = false;
|
|
3581
|
+
|
|
3582
|
+
// A task the user types is theirs to place, and the only session it can belong to is the
|
|
3583
|
+
// one on screen -- the project and all-sessions views span many task dirs, so there is no
|
|
3584
|
+
// single target to write into.
|
|
3585
|
+
function canAddTask() {
|
|
3586
|
+
return viewMode === 'session' && !!currentSessionId;
|
|
3587
|
+
}
|
|
3588
|
+
|
|
3589
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
3590
|
+
function startAddTask(tile) {
|
|
3591
|
+
if (addingTask) return;
|
|
3592
|
+
addingTask = true;
|
|
3593
|
+
|
|
3594
|
+
const input = document.createElement('input');
|
|
3595
|
+
input.type = 'text';
|
|
3596
|
+
input.className = 'form-input column-add-input';
|
|
3597
|
+
input.placeholder = 'Task subject, Enter to add';
|
|
3598
|
+
// The input mutates the column outside setColumnHtml — drop the cache so the
|
|
3599
|
+
// next render rebuilds instead of skipping on a stale match.
|
|
3600
|
+
pendingTasks._lastHtml = null;
|
|
3601
|
+
tile.replaceWith(input);
|
|
3602
|
+
input.focus();
|
|
3603
|
+
|
|
3604
|
+
const reset = () => {
|
|
3605
|
+
addingTask = false;
|
|
3606
|
+
renderKanban();
|
|
3607
|
+
};
|
|
3608
|
+
|
|
3609
|
+
const save = async () => {
|
|
3610
|
+
// Enter and blur both submit, and Enter's own save disables the input -- which blurs
|
|
3611
|
+
// it. Dropping both handlers first is what keeps that from posting the subject twice.
|
|
3612
|
+
input.onkeydown = null;
|
|
3613
|
+
input.onblur = null;
|
|
3614
|
+
|
|
3615
|
+
const subject = input.value.trim();
|
|
3616
|
+
if (!subject) return reset();
|
|
3617
|
+
input.disabled = true;
|
|
3618
|
+
const sessionId = currentSessionId;
|
|
3619
|
+
try {
|
|
3620
|
+
const res = await fetch(`/api/tasks/${sessionId}`, {
|
|
3621
|
+
method: 'POST',
|
|
3622
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3623
|
+
body: JSON.stringify({ subject }),
|
|
3624
|
+
});
|
|
3625
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
3626
|
+
// The response carries the finished task, and the watcher will resend it within the
|
|
3627
|
+
// SSE debounce anyway -- so show it now rather than paying a session refetch for it.
|
|
3628
|
+
const { task } = await res.json();
|
|
3629
|
+
currentTasks.push({ ...task, sessionId });
|
|
3630
|
+
addingTask = false;
|
|
3631
|
+
renderKanban();
|
|
3632
|
+
} catch (error) {
|
|
3633
|
+
console.error('Failed to create task:', error);
|
|
3634
|
+
showToast('Failed to create task', 'error');
|
|
3635
|
+
reset();
|
|
3636
|
+
}
|
|
3637
|
+
};
|
|
3638
|
+
|
|
3639
|
+
input.onkeydown = (e) => {
|
|
3640
|
+
e.stopPropagation();
|
|
3641
|
+
if (e.key === 'Enter') {
|
|
3642
|
+
e.preventDefault();
|
|
3643
|
+
save();
|
|
3644
|
+
} else if (e.key === 'Escape') {
|
|
3645
|
+
e.preventDefault();
|
|
3646
|
+
input.onblur = null;
|
|
3647
|
+
reset();
|
|
3648
|
+
}
|
|
3649
|
+
};
|
|
3650
|
+
input.onblur = () => save();
|
|
3651
|
+
}
|
|
3652
|
+
//#endregion
|
|
3653
|
+
|
|
3337
3654
|
//#region DRAG_DROP
|
|
3338
3655
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
3339
3656
|
function onCardDragStart(e) {
|
|
@@ -5659,25 +5976,179 @@ function isWaitingFresh() {
|
|
|
5659
5976
|
return Date.now() - new Date(currentWaiting.timestamp).getTime() < WAITING_TTL_MS;
|
|
5660
5977
|
}
|
|
5661
5978
|
|
|
5979
|
+
// Per-question selections for the AskUserQuestion form, keyed by question text
|
|
5980
|
+
// (the shape the tool's `answers` input expects). Picks hold arrays of option
|
|
5981
|
+
// labels (length <= 1 unless multiSelect); free text lives separately so a
|
|
5982
|
+
// multi-select can combine both, the way the TUI's "Other" row does. Reset
|
|
5983
|
+
// when the ask changes.
|
|
5984
|
+
let waitingAnswerDraft = {};
|
|
5985
|
+
let waitingCustomDraft = {};
|
|
5986
|
+
let waitingDraftId = null;
|
|
5987
|
+
let waitingQuestions = [];
|
|
5988
|
+
|
|
5989
|
+
function waitingAnswerFor(q) {
|
|
5990
|
+
const picks = waitingAnswerDraft[q.question] || [];
|
|
5991
|
+
const custom = waitingCustomDraft[q.question] || '';
|
|
5992
|
+
if (q.multiSelect) {
|
|
5993
|
+
const all = custom ? picks.concat(custom) : picks;
|
|
5994
|
+
return all.length ? all : null;
|
|
5995
|
+
}
|
|
5996
|
+
return picks[0] || custom || null;
|
|
5997
|
+
}
|
|
5998
|
+
|
|
5999
|
+
// The action buttons live in the modal's fixed footer (#msg-detail-waiting-footer),
|
|
6000
|
+
// not the scrollable body — long content must not push them out of reach.
|
|
6001
|
+
function renderWaitingFooter(tool, params) {
|
|
6002
|
+
if (currentWaiting?.kind === 'plan') {
|
|
6003
|
+
return isWaitingAnswerable() ? planApprovalControlsHtml('plan-reject-feedback') : '';
|
|
6004
|
+
}
|
|
6005
|
+
if (currentWaiting?.kind !== 'question') return waitingDecisionButtons('waiting-btn');
|
|
6006
|
+
if (!isWaitingAnswerable()) return '';
|
|
6007
|
+
if (tool !== 'AskUserQuestion') return '';
|
|
6008
|
+
const questions = Array.isArray(params?.questions) ? params.questions : [];
|
|
6009
|
+
if (!questions.length) return '';
|
|
6010
|
+
const picked = questions.filter((q) => waitingAnswerFor(q)).length;
|
|
6011
|
+
const partial = picked > 0 && picked < questions.length ? ` (${picked}/${questions.length})` : '';
|
|
6012
|
+
return `<button id="waiting-answer-btn" class="waiting-btn-allow" ${picked > 0 ? '' : 'disabled'} onclick="submitWaitingAnswers()">Answer${partial}</button>`;
|
|
6013
|
+
}
|
|
6014
|
+
|
|
6015
|
+
function renderWaitingActions(tool, params) {
|
|
6016
|
+
if (!isWaitingAnswerable() || currentWaiting.kind !== 'question' || tool !== 'AskUserQuestion') return '';
|
|
6017
|
+
waitingQuestions = Array.isArray(params?.questions) ? params.questions : [];
|
|
6018
|
+
if (!waitingQuestions.length) return '';
|
|
6019
|
+
const qs = waitingQuestions
|
|
6020
|
+
.map((q, idx) => {
|
|
6021
|
+
const picks = waitingAnswerDraft[q.question] || [];
|
|
6022
|
+
const opts = (q.options || [])
|
|
6023
|
+
.map((o) => {
|
|
6024
|
+
const label = typeof o === 'string' ? o : o?.label || '';
|
|
6025
|
+
const desc = typeof o === 'object' ? o?.description || '' : '';
|
|
6026
|
+
const descHtml = desc ? `<span class="waiting-option-desc">${renderMarkdown(desc)}</span>` : '';
|
|
6027
|
+
// The option `preview` field is the TUI's side-by-side visualization
|
|
6028
|
+
// pane (mockups, code snippets, diagrams) — rendered as markdown
|
|
6029
|
+
const preview = typeof o === 'object' ? o?.preview || '' : '';
|
|
6030
|
+
const previewHtml = preview ? `<span class="waiting-option-preview">${renderMarkdown(preview)}</span>` : '';
|
|
6031
|
+
return `<button class="waiting-option${picks.includes(label) ? ' selected' : ''}" data-label="${escapeHtml(label)}" onclick="selectWaitingAnswer(${idx}, this.dataset.label)"><span class="waiting-option-label">${escapeHtml(label)}</span>${descHtml}${previewHtml}</button>`;
|
|
6032
|
+
})
|
|
6033
|
+
.join('');
|
|
6034
|
+
const header = q.header ? `<span class="waiting-question-header">${escapeHtml(q.header)}</span>` : '';
|
|
6035
|
+
const multi = q.multiSelect ? '<span class="waiting-question-multi">multi-select</span>' : '';
|
|
6036
|
+
const input = `<input type="text" class="waiting-option-input" placeholder="Or type your own answer" value="${escapeHtml(waitingCustomDraft[q.question] || '')}" oninput="setWaitingCustomAnswer(${idx}, this)">`;
|
|
6037
|
+
return `<div class="waiting-question">${header}${multi}<div class="waiting-question-text">${renderMarkdown(q.question || '')}</div><div class="waiting-options">${opts}</div>${input}</div>`;
|
|
6038
|
+
})
|
|
6039
|
+
.join('');
|
|
6040
|
+
return `<div class="waiting-actions">${qs}</div>`;
|
|
6041
|
+
}
|
|
6042
|
+
|
|
6043
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
|
|
6044
|
+
function selectWaitingAnswer(qi, label) {
|
|
6045
|
+
const q = waitingQuestions[qi];
|
|
6046
|
+
if (!q) return;
|
|
6047
|
+
// Clicking a picked option unpicks it, in both modes
|
|
6048
|
+
const picks = waitingAnswerDraft[q.question] || [];
|
|
6049
|
+
if (q.multiSelect) {
|
|
6050
|
+
waitingAnswerDraft[q.question] = picks.includes(label) ? picks.filter((l) => l !== label) : picks.concat(label);
|
|
6051
|
+
} else if (picks.includes(label)) {
|
|
6052
|
+
delete waitingAnswerDraft[q.question];
|
|
6053
|
+
} else {
|
|
6054
|
+
waitingAnswerDraft[q.question] = [label];
|
|
6055
|
+
delete waitingCustomDraft[q.question];
|
|
6056
|
+
}
|
|
6057
|
+
showWaitingDetail();
|
|
6058
|
+
}
|
|
6059
|
+
|
|
6060
|
+
// Free-text path — the TUI's "type your own answer". Updates the draft and the
|
|
6061
|
+
// Answer button in place: a full re-render here would drop the input's focus
|
|
6062
|
+
// on every keystroke.
|
|
6063
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML oninput
|
|
6064
|
+
function setWaitingCustomAnswer(qi, el) {
|
|
6065
|
+
const q = waitingQuestions[qi];
|
|
6066
|
+
if (!q) return;
|
|
6067
|
+
const v = el.value.trim();
|
|
6068
|
+
if (v) waitingCustomDraft[q.question] = v;
|
|
6069
|
+
else delete waitingCustomDraft[q.question];
|
|
6070
|
+
if (v && !q.multiSelect) {
|
|
6071
|
+
delete waitingAnswerDraft[q.question];
|
|
6072
|
+
for (const b of el.closest('.waiting-question')?.querySelectorAll('.waiting-option.selected') || []) {
|
|
6073
|
+
b.classList.remove('selected');
|
|
6074
|
+
}
|
|
6075
|
+
}
|
|
6076
|
+
const btn = document.getElementById('waiting-answer-btn');
|
|
6077
|
+
if (btn) {
|
|
6078
|
+
const picked = waitingQuestions.filter((qq) => waitingAnswerFor(qq)).length;
|
|
6079
|
+
btn.disabled = picked === 0;
|
|
6080
|
+
btn.textContent =
|
|
6081
|
+
picked > 0 && picked < waitingQuestions.length ? `Answer (${picked}/${waitingQuestions.length})` : 'Answer';
|
|
6082
|
+
}
|
|
6083
|
+
}
|
|
6084
|
+
|
|
6085
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
|
|
6086
|
+
function submitWaitingAnswers() {
|
|
6087
|
+
// Partial submits are fine — send only the questions actually answered.
|
|
6088
|
+
// multiSelect answers go as arrays of labels (the shape the TUI validates).
|
|
6089
|
+
const answers = {};
|
|
6090
|
+
for (const q of waitingQuestions) {
|
|
6091
|
+
const a = waitingAnswerFor(q);
|
|
6092
|
+
if (a) answers[q.question] = a;
|
|
6093
|
+
}
|
|
6094
|
+
if (!Object.keys(answers).length) return;
|
|
6095
|
+
respondWaiting({ answers });
|
|
6096
|
+
}
|
|
6097
|
+
|
|
5662
6098
|
function showWaitingDetail() {
|
|
5663
6099
|
if (!isWaitingFresh()) return;
|
|
5664
6100
|
currentMsgDetailIdx = MSG_DETAIL_WAITING_IDX;
|
|
5665
6101
|
msgHighlightDimmed = false;
|
|
5666
6102
|
highlightSelectedMsg();
|
|
6103
|
+
// Follow-mode poll ticks re-run this whole render — carry any typed reject
|
|
6104
|
+
// feedback across the rebuild, but never onto a different ask.
|
|
6105
|
+
const sameAsk = currentWaiting.id === waitingDraftId;
|
|
6106
|
+
const feedbackDraft = sameAsk ? document.getElementById('plan-reject-feedback')?.value || '' : '';
|
|
6107
|
+
if (!sameAsk) {
|
|
6108
|
+
waitingDraftId = currentWaiting.id || null;
|
|
6109
|
+
waitingAnswerDraft = {};
|
|
6110
|
+
waitingCustomDraft = {};
|
|
6111
|
+
}
|
|
5667
6112
|
const tool = currentWaiting.toolName || 'unknown';
|
|
5668
6113
|
const label = getWaitingLabel(currentWaiting.kind, tool);
|
|
5669
6114
|
const body = document.getElementById('msg-detail-body');
|
|
5670
6115
|
let inputHtml = '';
|
|
6116
|
+
const params = parseWaitingInput();
|
|
5671
6117
|
if (currentWaiting.toolInput) {
|
|
5672
|
-
|
|
5673
|
-
try {
|
|
5674
|
-
pretty = JSON.stringify(JSON.parse(currentWaiting.toolInput), null, 2);
|
|
5675
|
-
} catch (_) {
|
|
5676
|
-
/* keep raw */
|
|
5677
|
-
}
|
|
6118
|
+
const pretty = params ? JSON.stringify(params, null, 2) : currentWaiting.toolInput;
|
|
5678
6119
|
inputHtml = `<pre class="${TINTED_PRE_CLASS}">${escapeHtml(pretty)}</pre>`;
|
|
5679
6120
|
}
|
|
5680
|
-
|
|
6121
|
+
const actionsHtml = renderWaitingActions(tool, params);
|
|
6122
|
+
const footerHtml = renderWaitingFooter(tool, params);
|
|
6123
|
+
// With actions on screen the form/buttons are the content — tuck the raw
|
|
6124
|
+
// JSON behind a toggle, but keep what the user is approving visible: for a
|
|
6125
|
+
// permission ask surface the command (or description) as a one-line summary
|
|
6126
|
+
if ((actionsHtml || footerHtml) && inputHtml) {
|
|
6127
|
+
let summaryHtml = '';
|
|
6128
|
+
if (currentWaiting.kind === 'plan' && typeof params?.plan === 'string' && params.plan) {
|
|
6129
|
+
// A plan ask IS the plan — render it in full so it can be reviewed and
|
|
6130
|
+
// approved right here (follow mode included) instead of a JSON dump. The
|
|
6131
|
+
// raw tool input is dropped entirely to match the saved-plan modal.
|
|
6132
|
+
inputHtml = `<div class="detail-desc rendered-md">${renderMarkdown(params.plan)}</div>`;
|
|
6133
|
+
} else {
|
|
6134
|
+
if (currentWaiting.kind !== 'question' && params) {
|
|
6135
|
+
const gist =
|
|
6136
|
+
typeof params.command === 'string'
|
|
6137
|
+
? params.command
|
|
6138
|
+
: typeof params.description === 'string'
|
|
6139
|
+
? params.description
|
|
6140
|
+
: '';
|
|
6141
|
+
if (gist) summaryHtml = `<pre class="${TINTED_PRE_CLASS}">${escapeHtml(gist)}</pre>`;
|
|
6142
|
+
}
|
|
6143
|
+
inputHtml = `${summaryHtml}<details class="waiting-raw-input"><summary>Raw tool input</summary>${inputHtml}</details>`;
|
|
6144
|
+
}
|
|
6145
|
+
}
|
|
6146
|
+
body.innerHTML = inputHtml + actionsHtml;
|
|
6147
|
+
document.getElementById('msg-detail-waiting-footer').innerHTML = footerHtml;
|
|
6148
|
+
if (feedbackDraft) {
|
|
6149
|
+
const feedbackEl = document.getElementById('plan-reject-feedback');
|
|
6150
|
+
if (feedbackEl) feedbackEl.value = feedbackDraft;
|
|
6151
|
+
}
|
|
5681
6152
|
document.getElementById('msg-detail-title').textContent = label;
|
|
5682
6153
|
document.getElementById('msg-detail-agent-btn').style.display = 'none';
|
|
5683
6154
|
const modal = document.getElementById('msg-detail-modal').querySelector('.modal');
|
|
@@ -6313,68 +6784,79 @@ function loadSidebarState() {
|
|
|
6313
6784
|
}
|
|
6314
6785
|
}
|
|
6315
6786
|
|
|
6316
|
-
|
|
6317
|
-
|
|
6318
|
-
|
|
6319
|
-
|
|
6787
|
+
// Shared drag-session lifecycle for every resize grip (sidebar, panels, modal):
|
|
6788
|
+
// the `.dragging` class, text-selection suppression, and document-level
|
|
6789
|
+
// listener add/remove live here once. Callers keep only their geometry.
|
|
6790
|
+
// onStart may return false to veto the drag.
|
|
6791
|
+
function _initDragResize(handle, { onStart, onMove, onEnd }) {
|
|
6792
|
+
let startX, startY;
|
|
6320
6793
|
|
|
6321
6794
|
handle.addEventListener('mousedown', (e) => {
|
|
6322
|
-
if (
|
|
6795
|
+
if (onStart && onStart() === false) return;
|
|
6323
6796
|
startX = e.clientX;
|
|
6324
|
-
|
|
6325
|
-
sidebar.classList.add('resizing');
|
|
6797
|
+
startY = e.clientY;
|
|
6326
6798
|
handle.classList.add('dragging');
|
|
6327
6799
|
document.body.style.userSelect = 'none';
|
|
6328
|
-
document.addEventListener('mousemove',
|
|
6329
|
-
document.addEventListener('mouseup',
|
|
6800
|
+
document.addEventListener('mousemove', move);
|
|
6801
|
+
document.addEventListener('mouseup', up);
|
|
6330
6802
|
e.preventDefault();
|
|
6331
6803
|
});
|
|
6332
6804
|
|
|
6333
|
-
function
|
|
6334
|
-
|
|
6335
|
-
sidebar.style.setProperty('--sidebar-width', `${w}px`);
|
|
6336
|
-
sidebar.style.width = `${w}px`;
|
|
6805
|
+
function move(e) {
|
|
6806
|
+
onMove(e.clientX - startX, e.clientY - startY);
|
|
6337
6807
|
}
|
|
6338
6808
|
|
|
6339
|
-
function
|
|
6340
|
-
sidebar.classList.remove('resizing');
|
|
6809
|
+
function up() {
|
|
6341
6810
|
handle.classList.remove('dragging');
|
|
6342
6811
|
document.body.style.userSelect = '';
|
|
6343
|
-
document.removeEventListener('mousemove',
|
|
6344
|
-
document.removeEventListener('mouseup',
|
|
6345
|
-
|
|
6812
|
+
document.removeEventListener('mousemove', move);
|
|
6813
|
+
document.removeEventListener('mouseup', up);
|
|
6814
|
+
onEnd();
|
|
6346
6815
|
}
|
|
6347
6816
|
}
|
|
6348
6817
|
|
|
6818
|
+
function initSidebarResize() {
|
|
6819
|
+
const sidebar = document.querySelector('.sidebar');
|
|
6820
|
+
const handle = document.getElementById('sidebar-resize');
|
|
6821
|
+
let startWidth;
|
|
6822
|
+
|
|
6823
|
+
_initDragResize(handle, {
|
|
6824
|
+
onStart() {
|
|
6825
|
+
if (sidebar.classList.contains('collapsed')) return false;
|
|
6826
|
+
startWidth = sidebar.offsetWidth;
|
|
6827
|
+
sidebar.classList.add('resizing');
|
|
6828
|
+
},
|
|
6829
|
+
onMove(dx) {
|
|
6830
|
+
const w = Math.min(600, Math.max(200, startWidth + dx));
|
|
6831
|
+
sidebar.style.setProperty('--sidebar-width', `${w}px`);
|
|
6832
|
+
sidebar.style.width = `${w}px`;
|
|
6833
|
+
},
|
|
6834
|
+
onEnd() {
|
|
6835
|
+
sidebar.classList.remove('resizing');
|
|
6836
|
+
localStorage.setItem('sidebar-width', sidebar.style.getPropertyValue('--sidebar-width'));
|
|
6837
|
+
},
|
|
6838
|
+
});
|
|
6839
|
+
}
|
|
6840
|
+
|
|
6349
6841
|
function initPanelResize(panelId, handleId, cssVar, storageKey) {
|
|
6350
6842
|
const panel = document.getElementById(panelId);
|
|
6351
6843
|
const handle = document.getElementById(handleId);
|
|
6352
|
-
let
|
|
6844
|
+
let startWidth;
|
|
6353
6845
|
|
|
6354
|
-
handle
|
|
6355
|
-
|
|
6356
|
-
|
|
6357
|
-
|
|
6358
|
-
|
|
6359
|
-
|
|
6360
|
-
|
|
6361
|
-
|
|
6362
|
-
|
|
6846
|
+
_initDragResize(handle, {
|
|
6847
|
+
onStart() {
|
|
6848
|
+
startWidth = panel.offsetWidth;
|
|
6849
|
+
panel.classList.add('resizing');
|
|
6850
|
+
},
|
|
6851
|
+
onMove(dx) {
|
|
6852
|
+
const w = Math.max(200, startWidth - dx);
|
|
6853
|
+
panel.style.setProperty(cssVar, `${w}px`);
|
|
6854
|
+
},
|
|
6855
|
+
onEnd() {
|
|
6856
|
+
panel.classList.remove('resizing');
|
|
6857
|
+
localStorage.setItem(storageKey, panel.style.getPropertyValue(cssVar));
|
|
6858
|
+
},
|
|
6363
6859
|
});
|
|
6364
|
-
|
|
6365
|
-
function onMove(e) {
|
|
6366
|
-
const w = Math.max(200, startWidth - (e.clientX - startX));
|
|
6367
|
-
panel.style.setProperty(cssVar, `${w}px`);
|
|
6368
|
-
}
|
|
6369
|
-
|
|
6370
|
-
function onUp() {
|
|
6371
|
-
panel.classList.remove('resizing');
|
|
6372
|
-
handle.classList.remove('dragging');
|
|
6373
|
-
document.body.style.userSelect = '';
|
|
6374
|
-
document.removeEventListener('mousemove', onMove);
|
|
6375
|
-
document.removeEventListener('mouseup', onUp);
|
|
6376
|
-
localStorage.setItem(storageKey, panel.style.getPropertyValue(cssVar));
|
|
6377
|
-
}
|
|
6378
6860
|
}
|
|
6379
6861
|
|
|
6380
6862
|
function loadPanelWidths() {
|
|
@@ -7043,6 +7525,15 @@ function openPlanModal() {
|
|
|
7043
7525
|
if (!_pendingPlanContent) return;
|
|
7044
7526
|
const body = document.getElementById('plan-modal-body');
|
|
7045
7527
|
body.innerHTML = renderMarkdown(_pendingPlanContent);
|
|
7528
|
+
// A saved plan opened while its approval ask is still pending (sidebar
|
|
7529
|
+
// click, 'p') is the same review moment — offer Approve/Reject here too.
|
|
7530
|
+
document.getElementById('plan-approval-footer')?.remove();
|
|
7531
|
+
if (currentWaiting?.kind === 'plan' && isWaitingAnswerable() && _planSessionId === currentSessionId) {
|
|
7532
|
+
const controls = document.createElement('div');
|
|
7533
|
+
controls.id = 'plan-approval-footer';
|
|
7534
|
+
controls.innerHTML = planApprovalControlsHtml('plan-modal-reject-feedback');
|
|
7535
|
+
document.querySelector('#plan-modal .modal-footer').prepend(controls);
|
|
7536
|
+
}
|
|
7046
7537
|
document.getElementById('plan-modal').classList.add('visible');
|
|
7047
7538
|
const keyHandler = (e) => {
|
|
7048
7539
|
if (e.key === 'Escape') {
|
|
@@ -7328,6 +7819,7 @@ try {
|
|
|
7328
7819
|
initSidebarResize();
|
|
7329
7820
|
applyModalZoom();
|
|
7330
7821
|
loadModalFullscreen();
|
|
7822
|
+
initModalResize();
|
|
7331
7823
|
loadPanelWidths();
|
|
7332
7824
|
initPanelResize('detail-panel', 'detail-panel-resize', '--detail-panel-width', 'detail-panel-width');
|
|
7333
7825
|
initPanelResize('message-panel', 'message-panel-resize', '--message-panel-width', 'message-panel-width');
|