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/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
- function getWaitingLabel(kind, tool) {
1184
- if (kind !== 'question') return `Awaiting permission: ${tool}`;
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 === 'question' && tool === 'ExitPlanMode') return 'Plan awaiting approval';
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
- let params = null;
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
- 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-time">waiting…</div></div>${discardBtn}</div>`;
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 = document.querySelector(`#${modalId} .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 = !document.querySelector(`#${modalId} .modal`).classList.contains('fullscreen');
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
- for (const btn of document.querySelectorAll('[id$="-fullscreen-btn"]')) {
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
- if (modal) _setModalWidth(modal, 'Expand', expand, '60vw', '60vw');
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 ? '&#x25B4;' : '&#x25BE;';
2548
2715
 
2549
2716
  const permHtml = permFresh
2550
- ? `<div class="permission-badge">${currentWaiting.kind === 'question' ? '❓ Question pending' : `⏳ Awaiting: ${escapeHtml(currentWaiting.toolName || 'unknown')}`}</div>`
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
- pendingTasks.innerHTML =
3304
- pending.length > 0
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
- completedTasks.innerHTML =
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
- let pretty = currentWaiting.toolInput;
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
- body.innerHTML = inputHtml;
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
- function initSidebarResize() {
6317
- const sidebar = document.querySelector('.sidebar');
6318
- const handle = document.getElementById('sidebar-resize');
6319
- let startX, startWidth;
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 (sidebar.classList.contains('collapsed')) return;
6795
+ if (onStart && onStart() === false) return;
6323
6796
  startX = e.clientX;
6324
- startWidth = sidebar.offsetWidth;
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', onMove);
6329
- document.addEventListener('mouseup', onUp);
6800
+ document.addEventListener('mousemove', move);
6801
+ document.addEventListener('mouseup', up);
6330
6802
  e.preventDefault();
6331
6803
  });
6332
6804
 
6333
- function onMove(e) {
6334
- const w = Math.min(600, Math.max(200, startWidth + e.clientX - startX));
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 onUp() {
6340
- sidebar.classList.remove('resizing');
6809
+ function up() {
6341
6810
  handle.classList.remove('dragging');
6342
6811
  document.body.style.userSelect = '';
6343
- document.removeEventListener('mousemove', onMove);
6344
- document.removeEventListener('mouseup', onUp);
6345
- localStorage.setItem('sidebar-width', sidebar.style.getPropertyValue('--sidebar-width'));
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 startX, startWidth;
6844
+ let startWidth;
6353
6845
 
6354
- handle.addEventListener('mousedown', (e) => {
6355
- startX = e.clientX;
6356
- startWidth = panel.offsetWidth;
6357
- panel.classList.add('resizing');
6358
- handle.classList.add('dragging');
6359
- document.body.style.userSelect = 'none';
6360
- document.addEventListener('mousemove', onMove);
6361
- document.addEventListener('mouseup', onUp);
6362
- e.preventDefault();
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');