claudeboard 2.11.0 → 2.13.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.
@@ -6,6 +6,7 @@ import fs from "fs";
6
6
  import path from "path";
7
7
  import { createRequire } from "module";
8
8
  import { createConnection } from "net";
9
+ import { spawn as _spawn, execSync } from "child_process";
9
10
 
10
11
  const require = createRequire(import.meta.url);
11
12
  const MAX_FIX_ATTEMPTS = 5;
@@ -149,14 +150,13 @@ async function tryStartExpo(projectPath, port) {
149
150
  done(false, `Expo did not become ready within 60s.\n${output.slice(-800)}`), 60000);
150
151
 
151
152
  try {
152
- const { spawn } = require("child_process");
153
153
  // Use iOS simulator if available, fallback to web
154
- const useIOS = process.env.CLAUDEBOARD_IOS === "1" || await isSimulatorAvailable();
154
+ const useIOS = process.env.CLAUDEBOARD_IOS === "1" || isSimulatorAvailableSync();
155
155
  const expoArgs = useIOS
156
156
  ? ["expo", "start", "--ios", "--port", String(port)]
157
157
  : ["expo", "start", "--web", "--port", String(port)];
158
158
 
159
- proc = spawn("npx", expoArgs, {
159
+ proc = _spawn("npx", expoArgs, {
160
160
  cwd: projectPath,
161
161
  env: (() => { const e = { ...process.env, CI: "1", EXPO_NO_INTERACTIVE: "1", EXPO_NO_DOTENV: "0" }; delete e.ANTHROPIC_API_KEY; return e; })(),
162
162
  stdio: "pipe",
@@ -320,13 +320,10 @@ function getFatalError(output) {
320
320
  }
321
321
 
322
322
  // ── iOS Simulator detection ───────────────────────────────────────────────────
323
- async function isSimulatorAvailable() {
323
+ function isSimulatorAvailableSync() {
324
324
  try {
325
- const { execSync } = await import("child_process");
326
325
  const output = execSync("xcrun simctl list devices booted 2>&1", { encoding: "utf8" });
327
- // Check if any device is already booted
328
326
  if (output.includes("Booted")) return true;
329
- // Check if any iOS runtime is available
330
327
  const runtimes = execSync("xcrun simctl list runtimes 2>&1", { encoding: "utf8" });
331
328
  return runtimes.includes("iOS") && !runtimes.includes("unavailable");
332
329
  } catch {
@@ -337,7 +334,6 @@ async function isSimulatorAvailable() {
337
334
  // Take a screenshot of the iOS simulator
338
335
  export async function screenshotSimulator(outputPath) {
339
336
  try {
340
- const { execSync } = await import("child_process");
341
337
  execSync(`xcrun simctl io booted screenshot "${outputPath}"`, { stdio: "pipe" });
342
338
  const data = fs.readFileSync(outputPath);
343
339
  return { success: true, base64: data.toString("base64"), path: outputPath };
@@ -349,7 +345,6 @@ export async function screenshotSimulator(outputPath) {
349
345
  // Tap on the iOS simulator at given coordinates
350
346
  export async function tapSimulator(x, y) {
351
347
  try {
352
- const { execSync } = await import("child_process");
353
348
  execSync(`xcrun simctl io booted tap ${x} ${y}`, { stdio: "pipe" });
354
349
  return true;
355
350
  } catch { return false; }
@@ -847,6 +847,36 @@
847
847
  border-color: rgba(248,113,113,0.5);
848
848
  }
849
849
 
850
+ /* ── CARD ACTION BAR (edit/delete/retry) ── */
851
+ .card-actions {
852
+ display: flex;
853
+ gap: 4px;
854
+ margin-top: 8px;
855
+ opacity: 0;
856
+ transition: opacity 0.15s;
857
+ }
858
+ .card:hover .card-actions { opacity: 1; }
859
+ .card-action-btn {
860
+ flex: 1;
861
+ padding: 4px 0;
862
+ border-radius: 4px;
863
+ border: 1px solid var(--border);
864
+ background: rgba(255,255,255,0.03);
865
+ color: var(--muted);
866
+ font-family: var(--mono);
867
+ font-size: 10px;
868
+ cursor: pointer;
869
+ transition: all 0.15s;
870
+ text-align: center;
871
+ }
872
+ .card-action-btn:hover { background: rgba(255,255,255,0.08); color: var(--text); }
873
+ .card-action-btn.retry { color: var(--yellow); border-color: rgba(251,191,36,0.3); }
874
+ .card-action-btn.retry:hover { background: rgba(251,191,36,0.1); }
875
+ .card-action-btn.del { color: var(--red); border-color: rgba(248,113,113,0.2); }
876
+ .card-action-btn.del:hover { background: rgba(248,113,113,0.12); }
877
+ .card-action-btn.edit { color: var(--accent); border-color: rgba(108,138,255,0.2); }
878
+ .card-action-btn.edit:hover { background: rgba(108,138,255,0.1); }
879
+
850
880
  /* Empty column state */
851
881
  .col-empty {
852
882
  text-align: center;
@@ -1011,6 +1041,46 @@
1011
1041
  </div>
1012
1042
  </div>
1013
1043
 
1044
+ <!-- EDIT TASK MODAL -->
1045
+ <div class="overlay" id="editModal" onclick="if(event.target===this)closeEditTask()">
1046
+ <div class="modal">
1047
+ <div class="modal-title" data-i18n="editTaskTitle">Edit Task</div>
1048
+ <input type="hidden" id="e-id">
1049
+ <div class="field">
1050
+ <label data-i18n="fieldTitle">Title</label>
1051
+ <input type="text" id="e-title">
1052
+ </div>
1053
+ <div class="field">
1054
+ <label data-i18n="fieldDesc">Description</label>
1055
+ <textarea id="e-desc" rows="4"></textarea>
1056
+ </div>
1057
+ <div class="modal-grid">
1058
+ <div class="field">
1059
+ <label data-i18n="fieldPriority">Priority</label>
1060
+ <select id="e-priority">
1061
+ <option value="high" data-i18n="prioHigh">High</option>
1062
+ <option value="medium" data-i18n="prioMed">Medium</option>
1063
+ <option value="low" data-i18n="prioLow">Low</option>
1064
+ </select>
1065
+ </div>
1066
+ <div class="field">
1067
+ <label data-i18n="fieldType">Type</label>
1068
+ <select id="e-type">
1069
+ <option value="feature" data-i18n="typeFeature">Feature</option>
1070
+ <option value="bug" data-i18n="typeBug">Bug</option>
1071
+ <option value="config" data-i18n="typeConfig">Config</option>
1072
+ <option value="refactor" data-i18n="typeRefactor">Refactor</option>
1073
+ <option value="test" data-i18n="typeTest">Test</option>
1074
+ </select>
1075
+ </div>
1076
+ </div>
1077
+ <div class="modal-actions">
1078
+ <button class="btn-cancel" onclick="closeEditTask()" data-i18n="cancel">Cancel</button>
1079
+ <button class="btn-create" onclick="submitEditTask()" data-i18n="saveTask">Save Changes</button>
1080
+ </div>
1081
+ </div>
1082
+ </div>
1083
+
1014
1084
  <!-- RETRY / EDIT MODAL -->
1015
1085
  <div class="overlay" id="retryModal" onclick="if(event.target===this)closeRetry()">
1016
1086
  <div class="modal">
@@ -1131,13 +1201,23 @@ function connectWS() {
1131
1201
  ws.onclose = () => { setWS(false); setTimeout(connectWS, 2000); };
1132
1202
  ws.onmessage = (e) => {
1133
1203
  const { event, data } = JSON.parse(e.data);
1134
- if (['task_update','task_added','task_started','task_complete','task_failed'].includes(event)) {
1204
+
1205
+ // Task state changes that require a board reload (status flipped, new task, deleted)
1206
+ if (['task_added','task_started','task_complete','task_failed','task_deleted'].includes(event)) {
1135
1207
  loadBoard();
1136
1208
  }
1209
+ // Reorder: just re-render from current board data — no network call needed
1210
+ if (event === 'task_reordered') {
1211
+ renderKanban();
1212
+ updateStats();
1213
+ }
1214
+ // Single task field update: patch in-memory and re-render (no full reload)
1215
+ if (event === 'task_update') {
1216
+ patchTaskInMemory(data);
1217
+ }
1137
1218
  if (event === 'log') {
1138
1219
  board.logs.unshift(data);
1139
1220
  if (activeTab === 'activity') renderLogs();
1140
- // If on detail tab, don't touch activityPane — it'll refresh next time they switch
1141
1221
  }
1142
1222
  if (event === 'expo_status') {
1143
1223
  setExpoStatus(data.status, data.url);
@@ -1151,6 +1231,12 @@ function connectWS() {
1151
1231
  function setWS(on) {
1152
1232
  document.getElementById('wsDot').className = 'ws-dot' + (on ? ' on' : '');
1153
1233
  document.getElementById('wsLabel').textContent = on ? t('live') : t('reconnecting');
1234
+ // Poll only when disconnected
1235
+ if (on) {
1236
+ if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
1237
+ } else {
1238
+ if (!pollInterval) pollInterval = setInterval(loadBoard, 10000);
1239
+ }
1154
1240
  }
1155
1241
 
1156
1242
  // ── DATA ─────────────────────────────────────────────────────────────────────
@@ -1250,13 +1336,45 @@ function cardHTML(task, position = null) {
1250
1336
  <span class="tag ${task.type}">${task.type}</span>
1251
1337
  ${shortEpic ? `<span class="card-epic">${esc(shortEpic)}</span>` : ''}
1252
1338
  </div>
1253
- ${isError ? `<button class="card-retry-btn" onclick="event.stopPropagation();openRetry('${task.id}')">${t('retryCard')} Edit</button>` : ''}
1339
+ <div class="card-actions" onclick="event.stopPropagation()">
1340
+ ${isError ? `<button class="card-action-btn retry" onclick="openRetry('${task.id}')">${t('retryCard')}</button>` : ''}
1341
+ <button class="card-action-btn edit" onclick="openEditTask('${task.id}')" title="Edit">✎</button>
1342
+ <button class="card-action-btn del" onclick="deleteTask('${task.id}')" title="Delete">✕</button>
1343
+ </div>
1254
1344
  </div>`;
1255
1345
  }
1256
1346
 
1257
- // ── DRAG & DROP ───────────────────────────────────────────────────────────────
1347
+ // ── IN-MEMORY PATCH (avoid full reload for minor updates) ─────────────────────
1348
+ function patchTaskInMemory(patch) {
1349
+ if (!board.epics) return;
1350
+ for (const epic of board.epics) {
1351
+ const idx = (epic.cb_tasks || []).findIndex(t => t.id === patch.id);
1352
+ if (idx !== -1) {
1353
+ epic.cb_tasks[idx] = { ...epic.cb_tasks[idx], ...patch };
1354
+ renderKanban();
1355
+ updateStats();
1356
+ return;
1357
+ }
1358
+ }
1359
+ // Task not found in memory yet (new orphan etc), do full reload
1360
+ loadBoard();
1361
+ }
1362
+
1363
+ // ── DRAG STATE ────────────────────────────────────────────────────────────────
1364
+ // We track order in JS so we don't depend on DOM state during drag
1365
+ let dragColumnOrder = {}; // { status: [id, id, ...] } — snapshot at dragstart
1258
1366
  function onDragStart(e, id) {
1259
1367
  draggedId = id;
1368
+
1369
+ // Snapshot current order of all columns from memory (reliable — not DOM-dependent)
1370
+ dragColumnOrder = {};
1371
+ for (const status of ['todo', 'in_progress', 'done', 'error']) {
1372
+ dragColumnOrder[status] = allTasks()
1373
+ .filter(t => t.status === status)
1374
+ .sort((a, b) => (a.priority_order ?? 99) - (b.priority_order ?? 99))
1375
+ .map(t => t.id);
1376
+ }
1377
+
1260
1378
  setTimeout(() => {
1261
1379
  const el = document.querySelector(`.card[data-id="${id}"]`);
1262
1380
  if (el) el.classList.add('dragging');
@@ -1317,50 +1435,49 @@ async function onDrop(e, status) {
1317
1435
  const insertBeforeId = col.dataset.insertBefore || '';
1318
1436
  delete col.dataset.insertBefore;
1319
1437
 
1320
- // Calculate new priority_order based on drop position
1321
- const colKey = { todo: 'todo', in_progress: 'prog', done: 'done', error: 'err' }[status] || status;
1322
- const tasks = allTasks().filter(t => t.status === status && t.id !== draggedId);
1323
-
1324
- let newOrder;
1438
+ // Use the JS snapshot (taken at dragstart) — not DOM which has .dragging gaps
1439
+ const sourceStatus = allTasks().find(t => t.id === draggedId)?.status || status;
1440
+ const columnIds = (dragColumnOrder[status] || []).filter(id => id !== draggedId);
1441
+
1442
+ let newOrderedIds;
1325
1443
  if (!insertBeforeId) {
1326
- // Dropped at end
1327
- const maxOrder = tasks.length ? Math.max(...tasks.map(t => t.priority_order || 0)) : 0;
1328
- newOrder = maxOrder + 1;
1444
+ newOrderedIds = [...columnIds, draggedId];
1329
1445
  } else {
1330
- const targetTask = tasks.find(t => t.id === insertBeforeId);
1331
- const targetOrder = targetTask?.priority_order ?? 1;
1332
- // Shift tasks after insertion point
1333
- newOrder = targetOrder - 0.5;
1446
+ const insertIdx = columnIds.indexOf(insertBeforeId);
1447
+ if (insertIdx === -1) {
1448
+ newOrderedIds = [draggedId, ...columnIds];
1449
+ } else {
1450
+ newOrderedIds = [...columnIds.slice(0, insertIdx), draggedId, ...columnIds.slice(insertIdx)];
1451
+ }
1334
1452
  }
1335
1453
 
1336
- await fetch(`/api/tasks/${draggedId}`, {
1337
- method: 'PATCH',
1338
- headers: { 'Content-Type': 'application/json' },
1339
- body: JSON.stringify({ status, priority_order: newOrder }),
1454
+ // Optimistic update in memory so re-render is instant
1455
+ const task = allTasks().find(t => t.id === draggedId);
1456
+ if (task) task.status = status;
1457
+ newOrderedIds.forEach((id, i) => {
1458
+ const t = allTasks().find(t => t.id === id);
1459
+ if (t) t.priority_order = i + 1;
1340
1460
  });
1461
+ renderKanban();
1462
+ updateStats();
1341
1463
 
1342
- // Re-normalize priority_order for all tasks in this column after drop
1343
- await normalizeColumnOrder(status);
1464
+ // Update status if column changed
1465
+ if (sourceStatus !== status) {
1466
+ await fetch(`/api/tasks/${draggedId}`, {
1467
+ method: 'PATCH',
1468
+ headers: { 'Content-Type': 'application/json' },
1469
+ body: JSON.stringify({ status }),
1470
+ });
1471
+ }
1344
1472
 
1345
- draggedId = null;
1346
- loadBoard();
1347
- }
1473
+ // Persist new order
1474
+ await fetch('/api/tasks/reorder', {
1475
+ method: 'POST',
1476
+ headers: { 'Content-Type': 'application/json' },
1477
+ body: JSON.stringify({ taskIds: newOrderedIds }),
1478
+ });
1348
1479
 
1349
- async function normalizeColumnOrder(status) {
1350
- // Get all tasks in column sorted by current priority_order, renumber 1..N
1351
- const tasks = allTasks()
1352
- .filter(t => t.status === status)
1353
- .sort((a, b) => (a.priority_order ?? 99) - (b.priority_order ?? 99));
1354
-
1355
- for (let i = 0; i < tasks.length; i++) {
1356
- if (tasks[i].priority_order !== i + 1) {
1357
- await fetch(`/api/tasks/${tasks[i].id}`, {
1358
- method: 'PATCH',
1359
- headers: { 'Content-Type': 'application/json' },
1360
- body: JSON.stringify({ priority_order: i + 1 }),
1361
- });
1362
- }
1363
- }
1480
+ draggedId = null;
1364
1481
  }
1365
1482
 
1366
1483
  // ── CARD SELECT ───────────────────────────────────────────────────────────────
@@ -1683,6 +1800,48 @@ function initTerminal() {
1683
1800
  });
1684
1801
  }
1685
1802
 
1803
+ // ── EDIT TASK ─────────────────────────────────────────────────────────────────
1804
+ function openEditTask(id) {
1805
+ const task = allTasks().find(t => t.id === id);
1806
+ if (!task) return;
1807
+ document.getElementById('e-id').value = id;
1808
+ document.getElementById('e-title').value = task.title || '';
1809
+ document.getElementById('e-desc').value = task.description || '';
1810
+ document.getElementById('e-priority').value = task.priority || 'medium';
1811
+ document.getElementById('e-type').value = task.type || 'feature';
1812
+ document.getElementById('editModal').style.display = 'flex';
1813
+ applyTranslations();
1814
+ }
1815
+
1816
+ function closeEditTask() {
1817
+ document.getElementById('editModal').style.display = 'none';
1818
+ }
1819
+
1820
+ async function submitEditTask() {
1821
+ const id = document.getElementById('e-id').value;
1822
+ const title = document.getElementById('e-title').value.trim();
1823
+ const description = document.getElementById('e-desc').value.trim();
1824
+ const priority = document.getElementById('e-priority').value;
1825
+ const type = document.getElementById('e-type').value;
1826
+ if (!title) return;
1827
+
1828
+ const priorityOrder = { high: 1, medium: 2, low: 3 };
1829
+ await fetch(`/api/tasks/${id}`, {
1830
+ method: 'PATCH',
1831
+ headers: { 'Content-Type': 'application/json' },
1832
+ body: JSON.stringify({ title, description, priority, type, priority_order: priorityOrder[priority] }),
1833
+ });
1834
+ closeEditTask();
1835
+ loadBoard();
1836
+ }
1837
+
1838
+ // ── DELETE TASK ───────────────────────────────────────────────────────────────
1839
+ async function deleteTask(id) {
1840
+ if (!confirm(currentLang === 'es' ? '¿Eliminar esta tarea?' : 'Delete this task?')) return;
1841
+ await fetch(`/api/tasks/${id}`, { method: 'DELETE' });
1842
+ loadBoard();
1843
+ }
1844
+
1686
1845
  // ── UTILS ─────────────────────────────────────────────────────────────────────
1687
1846
  function esc(s) {
1688
1847
  if (!s) return '';
@@ -1690,7 +1849,7 @@ function esc(s) {
1690
1849
  }
1691
1850
 
1692
1851
  document.addEventListener('keydown', e => {
1693
- if (e.key === 'Escape') { closeModal(); closeRetry(); }
1852
+ if (e.key === 'Escape') { closeModal(); closeRetry(); closeEditTask(); }
1694
1853
  if ((e.metaKey || e.ctrlKey) && e.key === 'n') { e.preventDefault(); openModal(); }
1695
1854
  });
1696
1855
 
@@ -1724,6 +1883,7 @@ const STRINGS = {
1724
1883
  expoIdle:'Expo not started. Click "Start Expo" to install dependencies and launch with tunnel.',
1725
1884
  scanWith:'SCAN WITH EXPO GO',
1726
1885
  retryCard:'↩ Retry',
1886
+ editTaskTitle:'Edit Task', saveTask:'Save Changes', deleteConfirm:'Delete this task?',
1727
1887
  },
1728
1888
  es: {
1729
1889
  todo:'pendiente', running:'en curso', done:'listo', failed:'fallido',
@@ -1753,6 +1913,7 @@ const STRINGS = {
1753
1913
  expoIdle:'Expo no iniciado. Hacé clic en "Iniciar Expo" para instalar dependencias y lanzar con tunnel.',
1754
1914
  scanWith:'ESCANEAR CON EXPO GO',
1755
1915
  retryCard:'↩ Reintentar',
1916
+ editTaskTitle:'Editar tarea', saveTask:'Guardar cambios', deleteConfirm:'¿Eliminar esta tarea?',
1756
1917
  }
1757
1918
  };
1758
1919
 
@@ -1790,8 +1951,9 @@ function applyTranslations() {
1790
1951
 
1791
1952
  // ── INIT ──────────────────────────────────────────────────────────────────────
1792
1953
  loadBoard();
1793
- setInterval(loadBoard, 8000);
1794
1954
  connectWS();
1955
+ // Only poll when WS is disconnected (fallback)
1956
+ let pollInterval = null;
1795
1957
 
1796
1958
  // Load expo status
1797
1959
  fetch('/api/expo/status').then(r => r.json()).then(d => setExpoStatus(d.status, d.url));
@@ -369,6 +369,26 @@ app.patch("/api/tasks/:id", async (req, res) => {
369
369
  res.json({ ok: true });
370
370
  });
371
371
 
372
+ // DELETE a task
373
+ app.delete("/api/tasks/:id", async (req, res) => {
374
+ await supabase.from("cb_logs").delete().eq("task_id", req.params.id);
375
+ await supabase.from("cb_tasks").delete().eq("id", req.params.id);
376
+ broadcast("task_deleted", { id: req.params.id });
377
+ res.json({ ok: true });
378
+ });
379
+
380
+ // POST reorder — receives ordered array of task ids for a column, renumbers them
381
+ app.post("/api/tasks/reorder", async (req, res) => {
382
+ const { taskIds } = req.body; // array of ids in new order
383
+ for (let i = 0; i < taskIds.length; i++) {
384
+ await supabase.from("cb_tasks")
385
+ .update({ priority_order: i + 1 })
386
+ .eq("id", taskIds[i]);
387
+ }
388
+ broadcast("task_reordered", { taskIds });
389
+ res.json({ ok: true });
390
+ });
391
+
372
392
  app.get("/api/tasks/:id/logs", async (req, res) => {
373
393
  const { data } = await supabase.from("cb_logs").select("*")
374
394
  .eq("task_id", req.params.id).order("created_at");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeboard",
3
- "version": "2.11.0",
3
+ "version": "2.13.0",
4
4
  "description": "AI engineering team — from PRD to working mobile app, autonomously",
5
5
  "type": "module",
6
6
  "bin": {