local-kanban 2.0.0-beta.2 → 2.0.0-beta.3

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/CHANGELOG.md CHANGED
@@ -8,6 +8,25 @@ version it goes out as and opens an empty one again. Released sections are histo
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [2.0.0-beta.3]
12
+
13
+ ### Fixed
14
+
15
+ - The mark «on main» counts a squash and a copy under another subject as shipped, no longer reads the head of the project, a foreign pull request or a mere mention as a task's commit, does not call a branch that took its work back shipped, finds a key after a number in the subject, costs a look a few git processes instead of four a commit, and leaves no objects in the project's repository.
16
+ - Ready to ship: Prep and a label set on the way are no road past the column; an emptied column goes at once; the Dashboard's «Waiting for you» lists what waits for your word, with its number in the sidebar; Done with Claude and the first-run tour know the column; a guest of the Wi-Fi board cannot change a project's own word.
17
+ - A label or a move the board refuses no longer holds the rest of the card: the label comes off with the reason said, and what you typed is saved in any order, after a lost connection and with the card closed too.
18
+ - A guest of the Wi-Fi board is told that only the owner can do this, instead of «the board did not accept it».
19
+ - **Wi-Fi: a guest could get the key out of a backup.** Backups hold the whole database, the key of the door with it, and a guest with a pass could take one and download it. Backups now stay with the owner, the list too, and Settings → Backups tells a guest so; About no longer gives a guest the paths on disk.
20
+ - **Wi-Fi: the key no longer stays behind in the page of the board.** A link opens the board by a redirect, so a script there finds nothing of the key in `document.referrer`, and Back does not lead to its address. A right key on a long address opens the board instead of an empty page, and a guest with words typed when the key changes is told to copy them and reload.
21
+ - The screens the terminal keeps to send only what changed take less memory and go when nobody looks.
22
+ - A comment is no longer written twice when you press send again while it waits, or «Send back» again with the same reason. One written with its answer lost no longer stands under itself as «not sent», and one deleted before the resend stays deleted.
23
+ - Automations: a board that has just restarted no longer types into a question or a limit screen it has not read yet, and going on with interrupted work leaves alone what is typed in the field, a task that left work and a session a day old; a card says the board went on only when the words got there.
24
+ - Automations: «Did not start» says in the board's language that a Claude already works on the tasks, or that tmux did not answer.
25
+ - `kb show A B`: a card whose comments did not load says it is cut short, and a refusal leaves no `---` behind.
26
+ - `kb note` no longer reports a failure for a note it saved, and an answer of the board that breaks off in the middle exits with code 4, not as a failure of `kb` itself.
27
+ - A release check names the file and line of what it found, and a broken private file of patterns is said in words.
28
+ - A release runs the test of the mark «on main» after the rest of the suite, not beside it: on a Mac the two together failed tmux tests at random and stopped the release.
29
+
11
30
  ## [2.0.0-beta.2]
12
31
 
13
32
  ### Added
package/bin/kb CHANGED
@@ -21,7 +21,7 @@ const ST_ALIAS = {
21
21
  // Exit codes are a contract for automation (scripts gate their moves on them):
22
22
  // 0 — success, 1 — the board is not responding, 2 — the request was rejected (4xx or an
23
23
  // unknown command), 3 — kb itself broke, 4 — the board answered, but with a failure of its
24
- // own (5xx) or with something that is not JSON, or not of the shape the board answers with.
24
+ // own (5xx) or with something that is not JSON, not of the shape the board answers with, or cut off.
25
25
  // Previously an exception exited with 1 as well, so "the board is down" was indistinguishable from
26
26
  // "kb is broken".
27
27
  // Code 4 exists because 2 was answering two different questions at once. A live board that
@@ -99,7 +99,11 @@ async function api(method, path, body, expect = null) {
99
99
  return data;
100
100
  };
101
101
  if (res.status === 204) return shaped(null);
102
- const text = await res.text();
102
+ // Headers came and the body did not finish: the board went down in the middle of its answer. That throws here,
103
+ // past the check above, and read as kb itself breaking.
104
+ let text;
105
+ try { text = await res.text(); }
106
+ catch { die(`kb: the board answered ${res.status} and broke off in the middle — whether it did what was asked is not known`, EXIT.BOARD_ERROR); }
103
107
  let data = null;
104
108
  let parsed = false;
105
109
  try { data = JSON.parse(text); parsed = true; } catch { /* not JSON — see below */ }
@@ -473,16 +477,22 @@ const commands = {
473
477
 
474
478
  // A batch is read in one go, so every key is printed, cards apart by a line of ---. A wrong key stops the
475
479
  // command before the first request; a task the board does not know stops it there, and says at which key.
480
+ // The line goes out once the next card is read, so a refused one leaves none hanging, and a card whose comments
481
+ // did not come is called cut short: its head is printed, and it does not look like a whole one.
476
482
  async show() {
477
483
  if (!args.length) die('kb show <key> [<key> …]');
478
484
  const ids = args.map(key);
479
485
  let at = 0;
486
+ let cut = null;
480
487
  process.on('exit', (code) => {
481
- if (code && ids.length > 1) process.stderr.write(`kb: stopped at ${args[at]}: ${at} of ${ids.length} printed\n`);
488
+ if (!code) return;
489
+ const part = cut ? `${cut} is cut short — its comments did not load` : '';
490
+ if (ids.length > 1) process.stderr.write(`kb: stopped at ${args[at]}: ${at} of ${ids.length} printed${part ? `, ${part}` : ''}\n`);
491
+ else if (part) process.stderr.write(`kb: ${part}\n`);
482
492
  });
483
493
  for (; at < ids.length; at++) {
484
- if (at) console.log('---');
485
494
  const t = await api('GET', `/api/tasks/${ids[at]}`, null, TASK);
495
+ if (at) console.log('---');
486
496
  console.log(`${t.key} ${t.project} ${t.status}${t.pinned ? ' ' + PIN : ''}${t.blocked ? ` blocked: ${t.blocked_reason || ''}` : ''}${PRI[t.priority] ? ' ' + PRI[t.priority] : ''}${t.labels.length ? ' [' + t.labels.join(',') + ']' : ''}`);
487
497
  console.log(t.title);
488
498
  if (t.description) console.log(t.description);
@@ -493,7 +503,9 @@ const commands = {
493
503
  printSessions(t);
494
504
  printUsage(t);
495
505
  printAttachments(t);
506
+ cut = t.key;
496
507
  printComments(await api('GET', `/api/tasks/${t.id}/comments`, null, LIST), 5);
508
+ cut = null;
497
509
  }
498
510
  },
499
511
 
@@ -591,9 +603,10 @@ const commands = {
591
603
  async note() {
592
604
  const [id, body] = args;
593
605
  if (!id || !body) die('kb note <key> "text"');
606
+ // Nothing is read after the write: a read that failed reported a failure of a note already saved, and the retry
607
+ // wrote a second one. So the key is printed as it was typed.
594
608
  await api('POST', `/api/tasks/${key(id)}/comments`, { body, author: 'claude' }, COMMENT);
595
- const t = await api('GET', `/api/tasks/${key(id)}`, null, TASK);
596
- console.log(`${t.key} 📝`);
609
+ console.log(`${id} 📝`);
597
610
  },
598
611
 
599
612
  // kb link A B [child|parent|related] — `child`: B becomes a sub-task of A, `parent`: B becomes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-kanban",
3
- "version": "2.0.0-beta.2",
3
+ "version": "2.0.0-beta.3",
4
4
  "license": "MIT",
5
5
  "author": "Dannylamao",
6
6
  "repository": {
@@ -8,6 +8,7 @@ import { checkWords } from './review.js';
8
8
  import { getSetting, openSettingsModal } from './settings.js';
9
9
  import { renderSidebar, selectProject, styledAlert } from './sidebar.js';
10
10
  import { refusalLine } from './unsaved.js';
11
+ import { failWord } from './review-rules.js';
11
12
 
12
13
  const CHECK_KIND = 'finished-check';
13
14
  const SCHEDULED = new Set(['schedule-check', 'schedule-groom']);
@@ -80,18 +81,7 @@ export function phaseLine(r) {
80
81
  }
81
82
  const phaseText = (r) => { const d = document.createElement('div'); d.innerHTML = phaseLine(r); return d.textContent; };
82
83
  const hm = (ms) => { const d = new Date(ms); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
83
- const FAIL_WORDS = {
84
- 'no-folder': 'the project has no folder on this computer',
85
- 'no-tmux': 'tmux is not installed here',
86
- 'no-claude': 'Claude Code is not installed here',
87
- 'too-long': 'the prompt is too long for the board\'s terminal',
88
- 'check-open': 'the project already has an open check',
89
- manual: 'a task done by hand cannot be given to Claude',
90
- };
91
- const failWhy = (r) => {
92
- const reason = String(r.error || '').match(/^([a-z-]+): /)?.[1];
93
- return reason && FAIL_WORDS[reason] ? tr(FAIL_WORDS[reason]) : esc(r.error || '');
94
- };
84
+ const failWhy = (r) => { const word = failWord(r.error); return word ? tr(word) : esc(r.error || ''); };
95
85
  function endedLine(r) {
96
86
  const key = r.check_key ? `<button type="button" class="auto-key" data-key="${esc(r.check_key)}">${esc(r.check_key)}</button>` : '';
97
87
  if (r.state === 'fired') return `${tr('Check started')} ${key} · ${hm(r.ended_at)}`;
@@ -15,7 +15,8 @@ import { autoSlot } from './automations.js';
15
15
  import { paintAttention } from './attention.js';
16
16
  import { refusalLine } from './unsaved.js';
17
17
  import { localStore } from './store.js';
18
- import { SHIP_TITLES, SHIP_WORDS, notOnMain, showsShip } from './shipped-rules.js';
18
+ import { SHIP_TITLES, SHIP_WORDS, notOnMain, shipAsker, showsShip } from './shipped-rules.js';
19
+ import { boardColumns } from './ready-rules.js';
19
20
 
20
21
  function matchesSearch(t) {
21
22
  return inSearch(t, { search: state.search, applied: state.searchApplied, hits: state.searchHits });
@@ -99,22 +100,17 @@ const SHIP_KEY = 'kb.ui.reviewNotMain';
99
100
  const SHIP_EVERY_MS = 30000;
100
101
  const shipFilter = () => localStore.getItem(SHIP_KEY) === '1';
101
102
  function setShipFilter(on) { try { localStore.setItem(SHIP_KEY, on ? '1' : '0'); } catch { /* the storage is off: the filter stays as it was */ } }
102
- let shipAsked = { slug: null, at: 0, flying: false };
103
- function askShipped() {
104
- const now = Date.now();
105
- if (shipAsked.flying || !state.tasks.some((t) => showsShip(t.status))) return;
106
- if (shipAsked.slug === state.slug && now - shipAsked.at < SHIP_EVERY_MS) return;
107
- shipAsked = { slug: state.slug, at: now, flying: true };
108
- const asked = state.slug;
109
- api('GET', `/api/shipped${asked === ALL ? '' : `?project=${encodeURIComponent(asked)}`}`, undefined, { quiet: true })
110
- .then((r) => {
111
- if (asked !== state.slug || JSON.stringify(r.tasks) === JSON.stringify(state.shipped)) return;
112
- state.shipped = r.tasks;
113
- renderBoard();
114
- })
115
- .catch(() => { /* the card says nothing: it is a hint, not a state of the task */ })
116
- .finally(() => { shipAsked.flying = false; });
117
- }
103
+ const askShipped = shipAsker({
104
+ every: SHIP_EVERY_MS,
105
+ board: () => state.slug,
106
+ wanted: () => state.tasks.some((t) => showsShip(t.status)),
107
+ ask: (slug) => api('GET', `/api/shipped${slug === ALL ? '' : `?project=${encodeURIComponent(slug)}`}`, undefined, { quiet: true }),
108
+ apply: (r) => {
109
+ if (JSON.stringify(r.tasks) === JSON.stringify(state.shipped)) return;
110
+ state.shipped = r.tasks;
111
+ renderBoard();
112
+ },
113
+ });
118
114
  const gateHere = () => (state.slug === ALL ? state.projects.some((p) => p.ship_gate) : Boolean(state.projects.find((p) => p.slug === state.slug)?.ship_gate));
119
115
  function columnHead(key, tasks, mode, labels) {
120
116
  const head = document.createElement('div');
@@ -216,9 +212,7 @@ export function renderBoard() {
216
212
  const sameBoard = prevRenderSlug === state.slug;
217
213
  const animate = prevCardIds.size > 0 && sameBoard;
218
214
  const labels = Object.fromEntries(ALL_STATUSES);
219
- const columns = ALL_STATUSES.map(([k]) => k)
220
- .filter((k) => (k !== 'cancelled' || state.tasks.some((t) => t.status === 'cancelled'))
221
- && (k !== 'ready' || readyInUse(state.slug) || state.tasks.some((t) => t.status === 'ready')));
215
+ const columns = boardColumns(ALL_STATUSES.map(([k]) => k), state.tasks, readyInUse(state.slug));
222
216
  const plan = columns.map((key) => {
223
217
  const mode = colSort(key);
224
218
  const hidden = key === 'review' && shipFilter() ? (t) => !notOnMain(t, state.shipped) : () => false;
package/public/js/core.js CHANGED
@@ -1,6 +1,7 @@
1
1
 
2
2
  import { createRetryQueue, resendDelay } from './unsaved.js';
3
3
  import { localStore, sessionStore } from './store.js';
4
+ import { readyShown, statusesShown } from './ready-rules.js';
4
5
 
5
6
  const ICONS = {
6
7
  grid: 'M2.5 2.5h4.5v4.5H2.5zM9 2.5h4.5v4.5H9zM2.5 9h4.5v4.5H2.5zM9 9h4.5v4.5H9z',
@@ -142,6 +143,7 @@ const I18N_RU = {
142
143
  'Port %s is taken by another program — the board did not open to the network. Free it or set another one in KB_SHARE_PORT.': 'Порт %s занят другой программой — доска в сеть не открылась. Освободи его или задай другой в KB_SHARE_PORT.',
143
144
  'Could not open the board to the network — the details are in Settings → Errors.': 'Не удалось открыть доску в сеть — подробности в «Настройки → Ошибки».',
144
145
  'You are a guest here': 'Ты здесь гость',
146
+ 'backups stay with the owner of the board: a backup holds its key': 'бэкапы остаются у владельца доски: в бэкапе лежит её ключ',
145
147
  'this board runs on another computer; its owner opens and closes it to the network': 'доска работает на другом компьютере; открывает и закрывает её для сети владелец',
146
148
  'The board stays on this computer: it has to be running, and switching off closes it to the network at once. After a restart of the board it is open again, with the same link.': 'Доска остаётся на этом компьютере: она должна работать, а выключатель сразу закрывает её для сети. После перезапуска доски вход снова открыт — по той же ссылке.',
147
149
  'On a Mac the system may ask whether node may accept incoming connections — allow it, or nobody gets in.': 'На маке система может спросить, разрешить ли node входящие подключения, — разреши, иначе никто не войдёт.',
@@ -349,6 +351,8 @@ const I18N_RU = {
349
351
  'The switch is off: the column goes once it is empty': 'Переключатель выключен: колонка уйдёт, когда опустеет',
350
352
  'Statuses': 'Статусы',
351
353
  'Wait for the word before shipping': 'Ждать команды перед выкаткой',
354
+ 'These projects say otherwise and do not wait:': 'Эти проекты решили иначе и не ждут:',
355
+ 'tasks waiting for your word to ship': 'задачи ждут твоей команды на выкатку',
352
356
  'a finished task stops in Ready to ship until you say to ship it; Ready to ship means it is not on main yet, Review that people have it': 'готовая задача останавливается в «Ждёт выкатки», пока ты не скажешь выкатывать; «Ждёт выкатки» — в main ещё нет, «Проверяю» — уже у людей',
353
357
  'A project can say otherwise in its settings.': 'Проект может решить иначе в своих настройках.',
354
358
  'Waits in the column:': 'В колонке ждут:',
@@ -1468,6 +1472,16 @@ const I18N_RU = {
1468
1472
  'Not saved: the board answered with an error — the details are in Settings → Errors.': 'Не сохранено: доска ответила ошибкой — подробности в «Настройках → Ошибки».',
1469
1473
  'Not saved: the task has to go through work first — the board showed it in an old column.': 'Не сохранено: задачу сначала нужно провести через работу — доска показывала её в старой колонке.',
1470
1474
  'Not saved: the board did not accept it.': 'Не сохранено: доска это не приняла.',
1475
+ 'Not saved: noclaude and check are not put on a task in work — this project ships on your word.': 'Не сохранено: noclaude и check не ставятся на задачу в работе — этот проект выкатывается по твоей команде.',
1476
+ 'noclaude and check are not put on a task in work — this project ships on your word': 'noclaude и check не ставятся на задачу в работе — этот проект выкатывается по твоей команде',
1477
+ 'Not saved: the task goes to Ready to ship first — this project ships on your word.': 'Не сохранено: задача сначала идёт в «Ждёт выкатки» — этот проект выкатывается по твоей команде.',
1478
+ 'the task goes to Ready to ship first — this project ships on your word': 'задача сначала идёт в «Ждёт выкатки» — этот проект выкатывается по твоей команде',
1479
+ 'Not saved: only the owner of the board can do this.': 'Не сохранено: это может только владелец доски.',
1480
+ 'only the owner of the board can do this': 'это может только владелец доски',
1481
+ 'a guest starts Claude only inside the board': 'гость запускает Claude только внутри доски',
1482
+ 'this board has no such label': 'на этой доске такой метки нет',
1483
+ 'Not saved: this device no longer has the key of the board, it may have been changed. Copy what you typed, then reload the page and enter the key.': 'Не сохранено: у этого устройства больше нет ключа доски, возможно, его сменили. Скопируй набранное, обнови страницу и введи ключ.',
1484
+ 'this device no longer has the key of the board, it may have been changed — copy what you typed, then reload the page and enter the key': 'у этого устройства больше нет ключа доски, возможно, его сменили — скопируй набранное, обнови страницу и введи ключ',
1471
1485
  'The task is not created': 'Задача не создана',
1472
1486
  'The task is not created yet: there is no connection to the board. It is created by itself once the connection is back.': 'Задача ещё не создана: нет связи с доской. Она создастся сама, когда связь вернётся.',
1473
1487
  'The task is not created yet: the board answered with an error — the details are in Settings → Errors. Your next change tries again, or click “not saved” at the top.': 'Задача ещё не создана: доска ответила ошибкой — подробности в «Настройках → Ошибки». Следующая правка попробует снова — или нажми «не сохранено» вверху.',
@@ -1625,6 +1639,7 @@ const I18N_RU = {
1625
1639
  'Its terminal, its steps and its questions are right in the task.': 'Его терминал, шаги и вопросы — прямо в задаче.',
1626
1640
  'Done is your word': '«Готово» — твоё слово',
1627
1641
  'Claude stops at Review: you accept the work or send it back.': 'Claude останавливается на «Проверяю»: ты принимаешь работу или возвращаешь её.',
1642
+ 'Claude stops at Ready to ship until you say to ship it, then at Review: you accept the work or send it back.': 'Claude останавливается на «Ждёт выкатки» до твоей команды, потом на «Проверяю»: ты принимаешь работу или возвращаешь её.',
1628
1643
  'Open issue': 'Открыта',
1629
1644
  'adds this': 'добавит это',
1630
1645
  'hides this': 'спрячет это',
@@ -1725,6 +1740,8 @@ const I18N_RU = {
1725
1740
  'Automations are switched off in Settings': 'Автоматизации выключены в Настройках',
1726
1741
  'the project has no folder on this computer': 'у проекта нет папки на этом компьютере',
1727
1742
  'tmux is not installed here': 'здесь не установлен tmux',
1743
+ 'a Claude of the board already works on these tasks': 'над этими задачами уже работает Claude доски',
1744
+ 'tmux did not answer in time': 'tmux не ответил вовремя',
1728
1745
  'Claude Code is not installed here': 'здесь не установлен Claude Code',
1729
1746
  "the prompt is too long for the board's terminal": 'задание слишком длинное для терминала доски',
1730
1747
  'the project already has an open check': 'в проекте уже открыта проверка',
@@ -1851,11 +1868,8 @@ export const ALL_STATUSES = [
1851
1868
  ['done', tr('Done')],
1852
1869
  ['cancelled', tr('Cancelled')],
1853
1870
  ];
1854
- export function readyInUse(slug) {
1855
- const on = (p) => Boolean(p.ship_gate || p.c_ready);
1856
- return slug === ALL ? state.projects.some(on) : on(state.projects.find((p) => p.slug === slug) || {});
1857
- }
1858
- export const statusesFor = (slug, current) => ALL_STATUSES.filter(([v]) => v !== 'ready' || v === current || readyInUse(slug));
1871
+ export const readyInUse = (slug) => readyShown(state.projects, slug, ALL);
1872
+ export const statusesFor = (slug, current) => statusesShown(ALL_STATUSES, current, readyInUse(slug));
1859
1873
  export const PRI_ICON = { 1: ['↓', 'p1', tr('low')], 2: ['=', 'p2', tr('medium')], 3: ['↑', 'p3', tr('high')] };
1860
1874
  export const PRI_LEVELS = [[3, tr('↑ High')], [2, tr('= Medium')], [1, tr('↓ Low')], [0, tr('No priority')]];
1861
1875
  export let LABEL_COLORS = {};
@@ -1,14 +1,14 @@
1
1
 
2
2
  import { wholeAnswer } from './term-delta.js';
3
3
  import { WORKING, buildSelect, canMoveTo, clearSelection, closeOpenSelect, colSort, labelChip, moveBlocked, revealCard, scheduleDrawLinks, selVal, selectTasks, selectedIds, setScrollToNewCardId, taskIsNoclaude } from './board.js';
4
- import { $, ALL, statusesFor, LABEL_COLORS, LABEL_SELECTABLE, PRI_LEVELS, TITLE_LABEL_WORDS, api, apiBlob, dropUnsaved, esc, ghSyncOn, ic, noteUnsaved, seg, state, tr } from './core.js';
4
+ import { $, ALL, statusesFor, LABEL_COLORS, LABEL_SELECTABLE, PRI_LEVELS, TITLE_LABEL_WORDS, api, apiBlob, dropUnsaved, esc, ghSyncOn, ic, noteUnsaved, retryUnsaved, seg, state, tr } from './core.js';
5
5
  import { columnComparator, DEFAULT_SORT } from './order.js';
6
6
  import { mountChat } from './chat.js';
7
7
  import { getSetting, openSettingsModal, plural, setClaudeView } from './settings.js';
8
8
  import { onPhone } from './phone-rules.js';
9
9
  import { NARROW, closeTopLayer, currentProject, layersOpen, overlayLayer, popLayer, pushLayer, selectProject, styledAlert, styledConfirm, watchLayers } from './sidebar.js';
10
10
  import { SHORT_MONTHS, askUsageNow, autoGrow, claudeInfoHTML, copyText, onClaudeInfo, launchInClaude, openSessionInClaude, refresh, relTime, renderTimeline, resumeOnBoard, taskPromptText } from './sse.js';
11
- import { canRetry, changedFields, createTurns, createUnsentComments, createUnsentEdits, imageTooLarge, pullExternal, refusalLine, refusalText, resendDelay } from './unsaved.js';
11
+ import { canRetry, changedFields, createCommentKeys, createTurns, createUnsentComments, createUnsentEdits, imageTooLarge, pullExternal, refusalLine, refusalText, refusedFields, resendDelay } from './unsaved.js';
12
12
  import { renderAsks } from './asks.js';
13
13
  import { stillOpen } from './review-rules.js';
14
14
  import { wireTermCopy } from './term-copy.js';
@@ -246,6 +246,7 @@ function drawComments(key, cs) {
246
246
  box.innerHTML = (cs || []).map(commentHTML).join('');
247
247
  commentsShown = { of: cs ? key : null, whole: Boolean(cs), ids: new Set((cs || []).map((c) => c.id)), max: Math.max(0, ...(cs || []).map((c) => c.id)) };
248
248
  wireComments(box);
249
+ for (const c of cs || []) commentOnServer(key, c);
249
250
  paintUnsent(key);
250
251
  }
251
252
  function commentHTML(c) {
@@ -275,6 +276,7 @@ function commentsFailed(key, e) {
275
276
  /** A comment of task `key` is known to be on the server — its event came, or the answer to the person's own.
276
277
  `c` — the comment, when it is known (a deleted one is not); `n` — how many the task holds now, when known. */
277
278
  export function commentArrived(key, c, n) {
279
+ commentOnServer(key, c);
278
280
  if (key !== state.drawerKey) return;
279
281
  const s = commentsShown;
280
282
  if (c && s.of === key && s.whole && !commentsRead) {
@@ -323,7 +325,15 @@ setInterval(refreshCommentTimes, 30000);
323
325
  document.addEventListener('visibilitychange', refreshCommentTimes);
324
326
 
325
327
  const unsentComments = createUnsentComments();
328
+ export const commentKeys = createCommentKeys();
329
+ export const unsentToken = (key, body) => unsentComments.tokenOf(key, body);
330
+ /** The server said the key is of a deleted comment: an entry under it would swallow new words of the same text. */
331
+ export function dropUnsentByToken(key, token) {
332
+ const c = unsentComments.byToken(key, token);
333
+ if (c) dropUnsentComment(key, c.id);
334
+ }
326
335
  export function queueComment(key, body, token) {
336
+ if (commentKeys.written(key, body)) return; // its event came first: only the answer was lost, nothing waits
327
337
  const c = unsentComments.add(key, body, token);
328
338
  noteUnsaved(c.again ? null : () => resendComment(key, c.id), `comment:${key}:${c.id}`);
329
339
  paintUnsent(key);
@@ -341,7 +351,13 @@ async function resendComment(key, id) {
341
351
  }
342
352
  unsentComments.done(key, id);
343
353
  paintUnsent(key);
344
- commentArrived(key, sent);
354
+ if (!sent?.deleted) commentArrived(key, sent); // deleted since it was written: delivered, and nothing to add to the list
355
+ }
356
+ function commentOnServer(key, c) {
357
+ if (!c?.client_key) return;
358
+ commentKeys.heard(c.client_key);
359
+ const waiting = unsentComments.byToken(key, c.client_key);
360
+ if (waiting) dropUnsentComment(key, waiting.id);
345
361
  }
346
362
  function paintUnsent(key) {
347
363
  if (key !== state.drawerKey) return;
@@ -2229,7 +2245,9 @@ async function doReturn(reason) {
2229
2245
  const key = state.drawerKey;
2230
2246
  try {
2231
2247
  if (reason) {
2232
- await api('POST', `/api/tasks/${seg(key)}/comments`, { body: `${tr('Send back')}: ${reason}`, author: 'me' });
2248
+ const body = `${tr('Send back')}: ${reason}`;
2249
+ await api('POST', `/api/tasks/${seg(key)}/comments`, { body, author: 'me', client_key: commentKeys.of(key, body) });
2250
+ commentKeys.done(key, body);
2233
2251
  const input = $('return-pop')?.querySelector('.return-pop-input');
2234
2252
  if (input) input.value = '';
2235
2253
  }
@@ -2436,7 +2454,10 @@ export async function doAutosave() {
2436
2454
  return await doAutosaveOnce();
2437
2455
  } catch (e) {
2438
2456
  if (e?.offline) return undefined;
2439
- if (e?.saidInCard) return undefined;
2457
+ if (e?.saidInCard) {
2458
+ if (e.restQueued) await retryUnsaved(); // the rest of a refused edit does not wait for the next try
2459
+ return undefined;
2460
+ }
2440
2461
  if (e?.taskKey && refusalText(e).gone) {
2441
2462
  if (e.taskKey === state.drawerKey) sayRefused(e, e.taskKey);
2442
2463
  else refresh();
@@ -2468,6 +2489,16 @@ async function resendTaskEdit(key) {
2468
2489
  try {
2469
2490
  saved = await editInTurn(key, () => api('PATCH', `/api/tasks/${seg(key)}`, sent));
2470
2491
  } catch (e) {
2492
+ const back = refusalText(e).gone ? [] : refusedFields(e, sent);
2493
+ if (back.length) { // the rest of the edit is not refused with the field: words typed offline still get there, open card or not
2494
+ unsentEdits.supersede(key, back);
2495
+ if (here && state.drawerKey === key) { takeBackRefused(back, e); sayFieldRefused(key, e); }
2496
+ else styledAlert(`${key}: ${refusalLine('The change is not saved', e, tr)}`);
2497
+ const left = { ...dirty };
2498
+ for (const f of back) delete left[f];
2499
+ if (Object.keys(left).length) unsentEdits.fail(key, left);
2500
+ return resendTaskEdit(key);
2501
+ }
2471
2502
  if (!e?.offline && !(e?.status >= 500)) { unsentEdits.drop(key); queuedBase.delete(key); }
2472
2503
  else unsentEdits.fail(key, dirty);
2473
2504
  throw e;
@@ -2484,19 +2515,44 @@ async function resendTaskEdit(key) {
2484
2515
  unsentEdits.settle(key, fields);
2485
2516
  unsentEdits.settle(key, dirty);
2486
2517
  if (here && state.drawerKey === key && drawerSnapshot) Object.assign(drawerSnapshot, dirty);
2487
- if (here && state.drawerKey === key) showSaveNote(null);
2518
+ if (here && state.drawerKey === key && refusedNote !== key) showSaveNote(null);
2488
2519
  }
2489
2520
  const unsentCreates = new Map();
2490
2521
  function editRefusedText(e) {
2491
2522
  if (e?.status >= 500) return tr('The change is not saved yet: the board answered with an error — the details are in Settings → Errors. Your next change tries again, or click “not saved” at the top.');
2492
2523
  return refusalLine('The change is not saved', e, tr);
2493
2524
  }
2525
+ /** The card lets go of what the server refused. A PATCH is all or nothing: left in the form, a refused field went out with
2526
+ every later edit and the board refused each whole, so words typed after it never got there, without a word when the card
2527
+ closed. Labels go back to what the server holds, and what depends on them with them. */
2528
+ function takeBackRefused(fields, e) {
2529
+ if (!fields.length) return;
2530
+ if (fields.includes('labels') && drawerSnapshot) {
2531
+ // the answer says what the server holds: a label set elsewhere while this one went must not be erased by the next save
2532
+ if (Array.isArray(e?.body?.labels)) drawerSnapshot.labels = [...e.body.labels];
2533
+ drawerLabels = [...drawerSnapshot.labels];
2534
+ closeLabelPicker();
2535
+ renderDrawerLabels();
2536
+ setDrawerLock(DrawerSync.cardLocked(drawerStatus, drawerLabels));
2537
+ launchFollowsLabels();
2538
+ }
2539
+ // the picker goes back to the status the card knows; a reading of the board brings the real one (`refresh` of the refusal)
2540
+ buildStatusSelect(drawerStatus, false, drawerLabels.includes('noclaude'));
2541
+ if (fields.includes('status') && drawerSnapshot) drawerSnapshot.status = drawerStatus;
2542
+ }
2494
2543
  function createRefusedText(e) {
2495
2544
  if (e?.offline) return tr('The task is not created yet: there is no connection to the board. It is created by itself once the connection is back.');
2496
2545
  if (e?.status >= 500) return tr('The task is not created yet: the board answered with an error — the details are in Settings → Errors. Your next change tries again, or click “not saved” at the top.');
2497
2546
  return refusalLine('The task is not created', e, tr);
2498
2547
  }
2548
+ // the note stands for a field refused while the rest of its edit is on the way: saving that rest proves nothing about the field
2549
+ let refusedNote = null;
2550
+ function sayFieldRefused(key, e) {
2551
+ showSaveNote(editRefusedText(e));
2552
+ refusedNote = key;
2553
+ }
2499
2554
  function showSaveNote(text) {
2555
+ refusedNote = null;
2500
2556
  const el = $('d-save-note');
2501
2557
  el.textContent = text || '';
2502
2558
  el.classList.toggle('hidden', !text);
@@ -2541,21 +2597,21 @@ async function doAutosaveOnce() {
2541
2597
  const withBase = () => ('description_base' in body && queuedBase.has(key) ? { ...body, description_base: queuedBase.get(key) } : body);
2542
2598
  try { saved = await editInTurn(key, () => api('PATCH', `/api/tasks/${seg(key)}`, withBase())); }
2543
2599
  catch (e) {
2544
- if (canRetry(e) && 'description' in body && !queuedBase.has(key)) queuedBase.set(key, body.description_base);
2600
+ const r = refusalText(e);
2601
+ const back = r.gone ? [] : refusedFields(e, patch); // the field the refusal is about; the rest of the edit is not refused with it
2602
+ const rest = { ...patch };
2603
+ for (const f of back) delete rest[f];
2604
+ const restQueued = back.length > 0 && Object.keys(rest).length > 0;
2605
+ if ((canRetry(e) || restQueued) && 'description' in body && !queuedBase.has(key)) queuedBase.set(key, body.description_base);
2545
2606
  if (canRetry(e)) queueTaskEdit(key, patch);
2546
2607
  // says it); the card moved on to another task while the request went: a refusal is said with the task's key
2547
- const r = refusalText(e);
2548
2608
  if (!e?.offline && !r.gone) {
2549
- // a refused move of the status: the picker goes back to the status the card knows — left on the refused one, it
2550
- // went out with every later edit, and the board refused each whole; the reading below brings the real status
2551
- if (!canRetry(e) && 'status' in patch && key === state.drawerKey) {
2552
- buildStatusSelect(drawerStatus, false, drawerLabels.includes('noclaude'));
2553
- if (drawerSnapshot) drawerSnapshot.status = drawerStatus;
2554
- }
2555
- if (key === state.drawerKey) showSaveNote(editRefusedText(e));
2609
+ if (key === state.drawerKey) takeBackRefused(back, e);
2610
+ if (restQueued) queueTaskEdit(key, rest); // as data: the card may close before it is sent
2611
+ if (key === state.drawerKey) { if (back.length) sayFieldRefused(key, e); else showSaveNote(editRefusedText(e)); }
2556
2612
  else if (!canRetry(e)) styledAlert(`${key}: ${refusalLine('The change is not saved', e, tr)}`);
2557
2613
  if (r.refresh) refresh(); // the card showed an old column: the board and the card are read again
2558
- Object.assign(e, { saidInCard: true });
2614
+ Object.assign(e, { saidInCard: true, restQueued });
2559
2615
  }
2560
2616
  throw Object.assign(e, { taskKey: key });
2561
2617
  } finally {
@@ -355,7 +355,7 @@ export function initGlyphs({ enabled } = {}) {
355
355
  pulse(e.clientX, e.clientY);
356
356
  }, { capture: true, passive: true });
357
357
  document.addEventListener('visibilitychange', syncGlyphs);
358
- const active = () => { activeAt = performance.now(); if (!frame) syncGlyphs(); };
358
+ const active = () => { activeAt = performance.now(); if (!frame && running()) syncGlyphs(); };
359
359
  for (const ev of ['pointermove', 'pointerdown', 'keydown', 'wheel', 'touchstart']) document.addEventListener(ev, active, { capture: true, passive: true });
360
360
  matchMedia('(prefers-reduced-motion: reduce)').addEventListener?.('change', syncGlyphs);
361
361
  syncGlyphs();
package/public/js/init.js CHANGED
@@ -3,7 +3,7 @@ import { commentToken } from './unsaved.js';
3
3
  import { clearSelection, closeOpenSelect, initMarquee, multiAction, renderBoard, scheduleDrawLinks, selectedIds } from './board.js';
4
4
  import { chaosPending } from './chaos.js';
5
5
  import { $, ALL, AUTO, HEALTH, CALENDAR, CHAOS, DASH, DONE, HORIZON, NOW, REVIEW, SETTINGS, api, hasUnsaved, ic, liftCurtain, loadLabelPalette, seg, setDoorClosed, state, tr } from './core.js';
6
- import { addChecklistItem, applyCommentDraft, applyKeepDrawer, applyTitleLabelShortcut, closeDrawer, commentDrafts, deleteTask, doAutosave, initTerminal, kbMoveCard, kbMoveCursor, launchOpenTask, onDrawerPaste, openDrawer, uploadCommentImage, wirePicturePick, wireTaskPicture, openDrawerMenu, openDrawerNew, openOpenTaskSession, openReturnPop, showOwnComment, pendingAttachments, pullDrawerFresh, queueComment, commentArrived, sayRefused, scheduleAutosave, setKbCursor, wireCardBack, descTyped, descConflictPending } from './drawer.js';
6
+ import { addChecklistItem, applyCommentDraft, applyKeepDrawer, applyTitleLabelShortcut, closeDrawer, commentDrafts, deleteTask, doAutosave, dropUnsentByToken, initTerminal, kbMoveCard, kbMoveCursor, launchOpenTask, onDrawerPaste, openDrawer, uploadCommentImage, wirePicturePick, wireTaskPicture, openDrawerMenu, openDrawerNew, openOpenTaskSession, openReturnPop, showOwnComment, pendingAttachments, pullDrawerFresh, queueComment, commentArrived, commentKeys, unsentToken, sayRefused, scheduleAutosave, setKbCursor, wireCardBack, descTyped, descConflictPending } from './drawer.js';
7
7
  import { closeProjectSettings, fileEditorHasChanges, openProjectSettings, projectSettingsDirty } from './project.js';
8
8
  import { applyCompact, applyTheme, customBinding, getSetting, listenForInstall, matchKey, skillDirty, syncLangToServer } from './settings.js';
9
9
  import { closeTopLayer, loadTasks, switchProject } from './sidebar.js';
@@ -61,23 +61,27 @@ $('project-menu').onclick = () => openProjectSettings();
61
61
  // but not for longer than COMMENT_HOLD_MS — a request that never answers must not lock the field of the task for good
62
62
  const commentSending = new Map();
63
63
  const COMMENT_HOLD_MS = 15000;
64
- const commentTokens = new Map();
65
64
  $('c-send').onclick = async () => {
66
65
  const body = $('c-input').value.trim();
67
66
  const key = state.drawerKey;
68
67
  if (!body || !key || Date.now() - (commentSending.get(key) ?? -Infinity) < COMMENT_HOLD_MS) return;
69
68
  const at = Date.now();
70
69
  commentSending.set(key, at);
71
- const sending = `${key}\n${body}`;
72
- const token = commentTokens.get(sending) ?? commentToken();
73
- commentTokens.set(sending, token);
70
+ let token = commentKeys.of(key, body, unsentToken(key, body));
71
+ const post = () => api('POST', `/api/tasks/${seg(key)}/comments`, { body, author: 'me', client_key: token });
74
72
  let sent = null;
75
- try { sent = await api('POST', `/api/tasks/${seg(key)}/comments`, { body, author: 'me', client_key: token }); }
76
- catch (e) {
73
+ try {
74
+ sent = await post();
75
+ if (sent?.deleted) { // the person deleted it and pressed now: these are new words, and no entry waits under the old key
76
+ dropUnsentByToken(key, token);
77
+ token = commentToken();
78
+ sent = await post();
79
+ }
80
+ } catch (e) {
77
81
  if (!e?.offline) { sayRefused(e, key); return; }
78
82
  queueComment(key, body, token);
79
83
  } finally { if (commentSending.get(key) === at) commentSending.delete(key); }
80
- commentTokens.delete(sending);
84
+ commentKeys.done(key, body);
81
85
  // typed on while it went: what is left after the sent text is a comment of its own — it stays, the sent text goes
82
86
  const rest = (text) => { const t = text.trimStart(); return t.startsWith(body) ? t.slice(body.length).trimStart() : text; };
83
87
  const draft = rest(commentDrafts.get(key) || '');
package/public/js/now.js CHANGED
@@ -1,4 +1,5 @@
1
1
 
2
+ import { handedIn } from './ready-rules.js';
2
3
  import { $, NOW, api, esc, ic, state, tr } from './core.js';
3
4
  import { openDrawer } from './drawer.js';
4
5
  import { selectProject } from './sidebar.js';
@@ -68,11 +69,8 @@ function runBadge(run) {
68
69
 
69
70
  // the queue are gone — the owner asked what they were for: the badge of the run already says what its Claude does, and
70
71
  // the line under the title says the stage and "working" or "next in line".
71
- function wasHanded(t) {
72
- return t.status === 'review' || t.status === 'done';
73
- }
74
72
  function mark(t) {
75
- return wasHanded(t) ? `<span class="now-check" title="${esc(STATUS_LABELS[t.status])}">✓</span>` : '';
73
+ return handedIn(t.status) ? `<span class="now-check" title="${esc(STATUS_LABELS[t.status])}">✓</span>` : '';
76
74
  }
77
75
 
78
76
  function taskLine(t, run, column) {
@@ -154,7 +152,7 @@ function runHead(run, column) {
154
152
 
155
153
  function runRows(run, column) {
156
154
  // the column of ✓ only in a run that handed something in — elsewhere the keys start at the edge
157
- const marks = run.tasks.some(wasHanded);
155
+ const marks = run.tasks.some((t) => handedIn(t.status));
158
156
  const rows = run.tasks.map((t) => `<div class="now-row" data-key="${esc(t.key)}" tabindex="0" role="button">`
159
157
  + (marks ? `<span class="now-mk">${mark(t)}</span>` : '')
160
158
  + `<span class="now-main"><span class="now-t"><span class="now-key">${esc(t.key)}</span><span class="now-title">${titleOf(t)}</span></span>`
@@ -33,6 +33,9 @@ export const FEATURES = [
33
33
  { id: 'accept', icon: 'check', title: 'Done is your word', note: 'Claude stops at Review: you accept the work or send it back.' },
34
34
  ];
35
35
 
36
+ const ACCEPT_GATED = 'Claude stops at Ready to ship until you say to ship it, then at Review: you accept the work or send it back.';
37
+ export const featuresFor = (gated) => FEATURES.map((f) => (gated && f.id === 'accept' ? { ...f, note: ACCEPT_GATED } : f));
38
+
36
39
  /** The ways ▶ can start Claude, in the order offered (the values of the `launch` setting), and why one cannot here:
37
40
  `avail` — the answer of GET /api/launch; before it comes nothing is said to be missing. */
38
41
  export function launchChoices(avail = {}) {
@@ -2,7 +2,7 @@ import { $, LANG, api, esc, ic, liftCurtain, smoothReload, state, tr } from './c
2
2
  import { setupView } from './claude-setup-rules.js';
3
3
  import { launchPick } from './launch-rules.js';
4
4
  import { renderLimits } from './limits.js';
5
- import { CONTACT_NAME, CONTACT_URL, FEATURES, STEP_HEADS, VIEW_CHOICES, accessLines, launchChoices, onboardingSteps, scanLines } from './onboarding-rules.js';
5
+ import { CONTACT_NAME, CONTACT_URL, FEATURES, STEP_HEADS, VIEW_CHOICES, accessLines, featuresFor, launchChoices, onboardingSteps, scanLines } from './onboarding-rules.js';
6
6
  import { sceneAccept, sceneAccess, sceneDemo, sceneNow, sceneScan, sceneSync, sceneTake, sceneView, sceneWatch, sceneWay } from './onboarding-scenes.js';
7
7
  import { appInstallView, appStepsHTML, applyCompact, getSetting, notifyPerm, notifyTestState, pickedSetting, promptInstall, sendTestNotification, setClaudeView, setSetting, syncLangToServer } from './settings.js';
8
8
  import { manualSetupHTML, selectProject, setupPromptBlockHTML, wireSetupPrompt } from './sidebar.js';
@@ -123,7 +123,7 @@ export function openOnboarding({ updated = state.projects.length > 0 } = {}) {
123
123
  <button type="button" class="${LANG === 'ru' ? 'on' : ''}" data-lang="ru">${LANG_NAMES.ru}</button>
124
124
  <button type="button" class="${LANG === 'en' ? 'on' : ''}" data-lang="en">${LANG_NAMES.en}</button>
125
125
  </div></div>`
126
- + `<div class="onb-list" id="onb-feats">${FEATURES.map((f, i) => `<button type="button" class="onb-item" data-i="${i}">`
126
+ + `<div class="onb-list" id="onb-feats">${featuresFor(state.projects.some((p) => p.ship_gate)).map((f, i) => `<button type="button" class="onb-item" data-i="${i}">`
127
127
  + `<span class="onb-ic">${ic(f.icon, 15)}</span><span class="onb-item-t"><b>${esc(tr(f.title))}</b><small>${esc(typeset(tr(f.note)))}</small></span>`
128
128
  + `<span class="onb-chev">${ic('chevron', 12)}</span></button>`).join('')}</div>`
129
129
  + `<div class="muted onb-contact">${tr('Questions about anything — write to me on Telegram:')} <a href="${CONTACT_URL}" target="_blank" rel="noopener noreferrer">${CONTACT_NAME}</a></div>`;
@@ -0,0 +1,25 @@
1
+
2
+ /** Whether the status is in use on a board: the switch is on for it, or tasks still stand in the column. Off and empty, the
3
+ status is in no menu and no column. `all` — the slug of the board of all projects. */
4
+ export function readyShown(projects, slug, all) {
5
+ const on = (p) => Boolean(p.ship_gate || p.c_ready);
6
+ return slug === all ? projects.some(on) : on(projects.find((p) => p.slug === slug) || {});
7
+ }
8
+ /** The statuses a menu offers: `current` stays, whatever the switch says. */
9
+ export const statusesShown = (statuses, current, shown) => statuses.filter(([v]) => v !== 'ready' || v === current || shown);
10
+ /** The columns of a board: Cancelled and Ready to ship only when there is a reason for them. */
11
+ export const boardColumns = (keys, tasks, shown) => keys.filter((k) => (k !== 'cancelled' || tasks.some((t) => t.status === 'cancelled'))
12
+ && (k !== 'ready' || shown || tasks.some((t) => t.status === 'ready')));
13
+ /** The number of «All projects» in the sidebar. */
14
+ export const activeTotal = (projects) => projects.reduce((s, p) => s + (p.c_todo || 0) + (p.c_prep || 0) + (p.c_doing || 0) + (p.c_ready || 0) + (p.c_deploy || 0) + (p.c_review || 0), 0);
15
+ /** The number of the Dashboard in the sidebar: its «Waiting for you» lists them, «Claude review» does not (it counts what it shows). */
16
+ export const waitingForWord = (projects) => projects.reduce((s, p) => s + (p.c_ready || 0), 0);
17
+ /** A task its Claude has handed in: the ✓ of «Claude now». */
18
+ export const handedIn = (status) => status === 'ready' || status === 'review' || status === 'done';
19
+ /** The line under the switch in Settings → Statuses, from GET /api/flow. */
20
+ export function flowNote(v, tr) {
21
+ const left = v.ready_n ? `${tr('Waits in the column:')} ${v.ready_n}` : '';
22
+ const gone = !v.ready_gate && v.left_n ? tr('The switch is off, and the tasks stay: the column goes once it is empty.') : '';
23
+ const own = v.ready_gate && v.own_off?.length ? `${tr('These projects say otherwise and do not wait:')} ${v.own_off.join(', ')}` : '';
24
+ return [gone, left, own].filter(Boolean).join(' · ');
25
+ }