local-kanban 2.0.0-beta.3 → 2.0.0-beta.5
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 +36 -0
- package/bin/kb +16 -2
- package/bin/local-kanban +2 -1
- package/package.json +1 -1
- package/public/js/board.js +33 -16
- package/public/js/chat.js +25 -23
- package/public/js/core.js +14 -0
- package/public/js/drawer.js +10 -5
- package/public/js/health-rules.js +2 -0
- package/public/js/health.js +42 -3
- package/public/js/now-terms.js +1 -1
- package/public/js/project.js +1 -1
- package/public/js/review-rules.js +1 -0
- package/public/js/review.js +1 -1
- package/public/js/sse.js +2 -1
- package/public/js/unsaved.js +32 -0
- package/public/style.css +2 -1
- package/src/auto-health.js +75 -3
- package/src/auto-resume.js +40 -9
- package/src/automations.js +13 -1
- package/src/bin-path.js +26 -0
- package/src/chat-feed.js +39 -23
- package/src/claude-live.js +9 -5
- package/src/launch.js +5 -11
- package/src/own-wait.js +5 -6
- package/src/phantom.js +32 -12
- package/src/repo-base.js +2 -1
- package/src/routes/chat.js +11 -13
- package/src/routes/dashboard.js +1 -1
- package/src/routes/health.js +8 -0
- package/src/routes/projects.js +19 -7
- package/src/routes/system.js +12 -10
- package/src/routes/tasks.js +36 -31
- package/src/routes/terminal.js +4 -1
- package/src/share.js +1 -1
- package/src/shipped.js +13 -1
- package/src/sync/github.js +3 -2
- package/src/tree-print.js +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,42 @@ 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.5]
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Going on with interrupted work: a card no longer says «Claude waits at its prompt» about a session that is at work after the board brought it back — the record is let go without a note, as it was before 2.0.0-beta.4.
|
|
16
|
+
- A title or description the board refused no longer makes an open card send its edit again and again: it stays in the field as typed, the card says it is not saved, and the rest of the edit is saved once.
|
|
17
|
+
- Health: the Russian text of the task «Fix» opens puts its counts of lines and characters in the right grammatical form.
|
|
18
|
+
- Health: «Fix» opens one task even when it is pressed many times at once, and the board refuses it for a CLAUDE.md that is within the norms, by the same rule as the button.
|
|
19
|
+
- Chat: a workflow or a background command still running in a turn older than the ones shown is listed while Claude waits for it, and work with no end and no word for over 2 hours is shown as «no word from it for over 2 h» instead of running for ever.
|
|
20
|
+
- A tmux updated while its old server still runs no longer answers «try again in a few seconds» — the board says which tmux server to stop.
|
|
21
|
+
- A limit line split by the terminal inside the date or the words is read whole, so the reset time is right.
|
|
22
|
+
- Dropping a group of cards shows the page's and the board's refusals in one window.
|
|
23
|
+
- Over Wi-Fi, a HEAD request for a page a guest may open passes like GET.
|
|
24
|
+
- `kb note` with a task typed as `1` after `DM-1` no longer writes the same note twice.
|
|
25
|
+
- A merge commit is told by its parents, not by a message starting with «Merge».
|
|
26
|
+
|
|
27
|
+
## [2.0.0-beta.4]
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- Health: a CLAUDE.md past a norm has «Fix» — it opens the task to shrink the file, with the docs of Claude Code, and starts Claude on it at once; while a Claude works in the project's folder, or a task of the project is in work, the button says what holds it and starts nothing.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- `kb note` and `kb review` send their note with a key, so a retry after a lost answer finds the note already written instead of adding a second one.
|
|
36
|
+
- **Wi-Fi: a guest no longer sees where the project's notes and deploy skill lie on the owner's disk.**
|
|
37
|
+
- The commit line of a card no longer shows a foreign pull request with the same number or the newest commit of another task.
|
|
38
|
+
- A card in Ready to ship says «waiting for you» when its Claude waits there for your word, with no second sound for it.
|
|
39
|
+
- A Claude that got one of its helpers back and waits for the next no longer reads as waiting for you.
|
|
40
|
+
- A group action or a drag the board refused says which tasks did not change and why, instead of putting cards back without a word; a refused title or description no longer holds the rest of the card's edit.
|
|
41
|
+
- Automations: a rule waits for the board to read the terminals after a restart before it starts a check, a pause after a refused start outlives a restart, words that cannot reach Claude stop after three tries, a card says when the board could not tell Claude to go on, and a limit line wrapped in the middle of its time keeps am/pm.
|
|
42
|
+
- Going on with interrupted work: tmux too slow to answer no longer uses up the tries of a session, and a card the board could not set going says what held it.
|
|
43
|
+
- On a Mac the board starts git and its other programs by their full path: a bare name was a short-lived process for every folder of PATH in front of it, and their stream held the start of tmux for seconds. The tests of tmux no longer fail beside the test of the mark «on main», the suite runs in 55 s instead of 92, and a release runs it at once again.
|
|
44
|
+
- The chat keeps words with pictures you sent while Claude worked: they vanished from it the moment Claude took them.
|
|
45
|
+
- A terminal tmux is slow to answer about no longer reads as «Not running», nor its chat as «does not run inside the board»: the screen and its state stay until tmux answers.
|
|
46
|
+
|
|
11
47
|
## [2.0.0-beta.3]
|
|
12
48
|
|
|
13
49
|
### Fixed
|
package/bin/kb
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { parsePm2Services } from '../src/pm2-services.js';
|
|
6
6
|
import { closeSync, writeSync } from 'node:fs';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
7
8
|
import { memoryText, moneyText, tokensText } from '../public/js/usage-rules.js';
|
|
8
9
|
import { HOOK_WAIT_MS } from '../src/claude-hooks.js';
|
|
9
10
|
|
|
@@ -56,6 +57,8 @@ const TASK = ['a task', (d) => isObj(d) && typeof d.key === 'string' && typeof d
|
|
|
56
57
|
const LIST = ['a list', Array.isArray];
|
|
57
58
|
const PROJECT = ['a project', (d) => isObj(d) && typeof d.slug === 'string'];
|
|
58
59
|
const COMMENT = ['a comment', (d) => isObj(d) && Number.isInteger(d.id)];
|
|
60
|
+
// was on the card and someone deleted it there.
|
|
61
|
+
const NOTE = ['a comment', (d) => COMMENT[1](d) || (isObj(d) && d.deleted === true)];
|
|
59
62
|
const ITEM = ['a checklist item', (d) => isObj(d) && typeof d.text === 'string'];
|
|
60
63
|
const DOCS = ['the project documents', (d) => isObj(d) && Array.isArray(d.docs)];
|
|
61
64
|
const TAKEN = ['a taken task', (d) => isObj(d) && Number.isInteger(d.blocked_n) && (d.task === null || TASK[1](d.task))];
|
|
@@ -72,6 +75,17 @@ function key(id) {
|
|
|
72
75
|
if (!KEY_RE.test(id)) die(`kb: "${id}" is not a task key (like CK-12) or a task id`);
|
|
73
76
|
return encodeURIComponent(id);
|
|
74
77
|
}
|
|
78
|
+
// after the board wrote the note (code 1), and the retry is a new kb with nothing remembered — the same key lets
|
|
79
|
+
// the board find what it already wrote instead of writing it twice. The same words from the same session again
|
|
80
|
+
// stay one note; that repeats nothing a person would want twice. The task is not in the key: the board keeps keys
|
|
81
|
+
function noteKey(body) {
|
|
82
|
+
return 'kb-' + createHash('sha256').update(`${SESSION}\n${body}`).digest('hex').slice(0, 40);
|
|
83
|
+
}
|
|
84
|
+
async function postNote(id, body) {
|
|
85
|
+
const c = await api('POST', `/api/tasks/${key(id)}/comments`, { body, author: 'claude', client_key: noteKey(body) }, NOTE);
|
|
86
|
+
if (c.deleted) die(`kb: the same note was on ${id} and was deleted there — not written again; change the words to add it`);
|
|
87
|
+
return c;
|
|
88
|
+
}
|
|
75
89
|
function seg(s) {
|
|
76
90
|
if (s === '.' || s === '..') die(`kb: "${s}" is not a project`);
|
|
77
91
|
return encodeURIComponent(s);
|
|
@@ -470,7 +484,7 @@ const commands = {
|
|
|
470
484
|
const t = await api('PATCH', `/api/tasks/${key(id)}`, { status: 'review' }, TASK);
|
|
471
485
|
const unsaved = (code) => { if (code) process.stderr.write(`kb: ${t.key} is in review, but the note did not save — kb note ${t.key} "…"\n`); };
|
|
472
486
|
process.on('exit', unsaved);
|
|
473
|
-
await
|
|
487
|
+
await postNote(id, note);
|
|
474
488
|
process.off('exit', unsaved);
|
|
475
489
|
console.log(`${t.key} review 📝`);
|
|
476
490
|
},
|
|
@@ -605,7 +619,7 @@ const commands = {
|
|
|
605
619
|
if (!id || !body) die('kb note <key> "text"');
|
|
606
620
|
// Nothing is read after the write: a read that failed reported a failure of a note already saved, and the retry
|
|
607
621
|
// wrote a second one. So the key is printed as it was typed.
|
|
608
|
-
await
|
|
622
|
+
await postNote(id, body);
|
|
609
623
|
console.log(`${id} 📝`);
|
|
610
624
|
},
|
|
611
625
|
|
package/bin/local-kanban
CHANGED
|
@@ -13,6 +13,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
13
13
|
import { existsSync, lstatSync, writeFileSync, mkdirSync, symlinkSync } from 'node:fs';
|
|
14
14
|
import { join, dirname, sep } from 'node:path';
|
|
15
15
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
16
|
+
import { bin } from '../src/bin-path.js'; // a program by its full path, as the board starts them
|
|
16
17
|
|
|
17
18
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
19
|
|
|
@@ -56,7 +57,7 @@ function ghReady() {
|
|
|
56
57
|
// the sync needs more than authentication: without the project scope create_issue succeeds
|
|
57
58
|
// but the card never lands on the Projects v2 board, and the user hunts for the cause
|
|
58
59
|
try {
|
|
59
|
-
const out = execFileSync('gh', ['auth', 'status'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
60
|
+
const out = execFileSync(bin('gh'), ['auth', 'status'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
60
61
|
return { ok: true, project: /\bproject\b/.test(out) };
|
|
61
62
|
} catch {
|
|
62
63
|
return { ok: false, project: false };
|
package/package.json
CHANGED
package/public/js/board.js
CHANGED
|
@@ -13,7 +13,7 @@ import { columnCheck, reviewGroups } from './review-rules.js';
|
|
|
13
13
|
import { heldWhy, startCheckIn } from './review.js';
|
|
14
14
|
import { autoSlot } from './automations.js';
|
|
15
15
|
import { paintAttention } from './attention.js';
|
|
16
|
-
import { refusalLine } from './unsaved.js';
|
|
16
|
+
import { refusalLine, refusalText } from './unsaved.js';
|
|
17
17
|
import { localStore } from './store.js';
|
|
18
18
|
import { SHIP_TITLES, SHIP_WORDS, notOnMain, shipAsker, showsShip } from './shipped-rules.js';
|
|
19
19
|
import { boardColumns } from './ready-rules.js';
|
|
@@ -586,10 +586,27 @@ function renderMultiPanel() {
|
|
|
586
586
|
+ `<button class="multi-clear" data-act="clear">${tr('Clear selection')}</button>`;
|
|
587
587
|
box.querySelectorAll('[data-act]').forEach((b) => { b.onclick = () => multiAction(b.dataset.act, b); });
|
|
588
588
|
}
|
|
589
|
-
|
|
589
|
+
export function refusedManyText(head, refused, t = tr) {
|
|
590
|
+
const by = new Map();
|
|
591
|
+
for (const { key, e, why: said } of refused) {
|
|
592
|
+
const why = said ?? t(refusalText(e).reason);
|
|
593
|
+
by.set(why, [...(by.get(why) || []), key]);
|
|
594
|
+
}
|
|
595
|
+
const keys = (ks) => (ks.length > 6 ? `${ks.slice(0, 6).join(', ')} +${ks.length - 6}` : ks.join(', '));
|
|
596
|
+
const lines = [...by].map(([why, ks]) => `${keys(ks)}: ${why}`);
|
|
597
|
+
return lines.length === 1 ? `${t(head)} — ${lines[0]}` : `${t(head)}:\n${lines.join('\n')}`;
|
|
598
|
+
}
|
|
599
|
+
function sayRefusedMany(head, refused) {
|
|
600
|
+
if (refused.length) styledAlert(refusedManyText(head, refused));
|
|
601
|
+
}
|
|
602
|
+
async function multiPatchEach(bodyFor, head = 'Not saved') {
|
|
590
603
|
const tasks = selectedTaskList();
|
|
591
|
-
|
|
604
|
+
const refused = [];
|
|
605
|
+
for (const t of tasks) {
|
|
606
|
+
try { await api('PATCH', `/api/tasks/${seg(t.key)}`, bodyFor(t), { quiet: true }); } catch (e) { refused.push({ key: t.key, e }); }
|
|
607
|
+
}
|
|
592
608
|
await refresh();
|
|
609
|
+
sayRefusedMany(head, refused);
|
|
593
610
|
}
|
|
594
611
|
function renderMultiSub(title, optsHTML, onPick) {
|
|
595
612
|
const box = $('drawer-multi');
|
|
@@ -608,7 +625,7 @@ export async function multiAction(act, btn) {
|
|
|
608
625
|
}).join('');
|
|
609
626
|
renderMultiSub(tr('Move to status'), opts, async (o) => {
|
|
610
627
|
if (o.classList.contains('disabled')) return;
|
|
611
|
-
await multiPatchEach(() => ({ status: o.dataset.v }));
|
|
628
|
+
await multiPatchEach(() => ({ status: o.dataset.v }), 'Not moved');
|
|
612
629
|
renderMultiPanel();
|
|
613
630
|
});
|
|
614
631
|
return;
|
|
@@ -779,7 +796,7 @@ const LIVE_BADGE = {
|
|
|
779
796
|
function liveBadge(t) {
|
|
780
797
|
if (hasAsk(t.id)) return `<span class="card-live live-wait live-ask" title="${esc(tr('Claude is waiting for your answer — open the card'))}"><span class="live-dot"></span>${tr('needs your answer')}</span>`;
|
|
781
798
|
const l = state.live[t.id];
|
|
782
|
-
const b = l && WORKING.has(t.status) && LIVE_BADGE[l.claude === 'gone' && l.interrupted ? 'interrupted'
|
|
799
|
+
const b = l && (WORKING.has(t.status) || t.status === 'ready') && LIVE_BADGE[l.claude === 'gone' && l.interrupted ? 'interrupted'
|
|
783
800
|
: l.claude === 'idle' && l.broke ? (l.broke === 'retrying' ? 'brokeRetrying' : 'brokeStuck') : l.claude];
|
|
784
801
|
if (!b) return '';
|
|
785
802
|
const until = l.claude === 'limit' && l.resets ? ` · ${tr('until')} ${esc(l.resets)}` : '';
|
|
@@ -938,10 +955,12 @@ async function onDrop(e, status, body) {
|
|
|
938
955
|
}
|
|
939
956
|
dropPh.remove();
|
|
940
957
|
try {
|
|
941
|
-
await api('PATCH', `/api/tasks/${seg(id)}`, patch);
|
|
958
|
+
await api('PATCH', `/api/tasks/${seg(id)}`, patch, { quiet: true });
|
|
942
959
|
} catch (e) {
|
|
943
960
|
renderBoard();
|
|
944
|
-
if (e?.offline) noteUnsaved(() => api('PATCH', `/api/tasks/${seg(id)}`, patch).then(refresh));
|
|
961
|
+
if (e?.offline) { noteUnsaved(() => api('PATCH', `/api/tasks/${seg(id)}`, patch).then(refresh)); return; }
|
|
962
|
+
sayRefusedMany('Not moved', [{ key: t.key, e }]);
|
|
963
|
+
await refresh();
|
|
945
964
|
return;
|
|
946
965
|
}
|
|
947
966
|
await refresh();
|
|
@@ -954,14 +973,10 @@ async function onDropGroup(e, status, body) {
|
|
|
954
973
|
.filter(Boolean)
|
|
955
974
|
.filter((t) => !DRAG_LOCKED.has(t.status))
|
|
956
975
|
.filter((t) => !moveBlocked(t.status, status, { manual: taskIsNoclaude(t) }));
|
|
957
|
-
const
|
|
958
|
-
.filter((t) => !movable.includes(t))
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
.find(Boolean) || tr('Claude moves these columns itself');
|
|
962
|
-
styledAlert(`${refused.length} ${plural(refused.length, 'task', 'tasks')}: ${why}`);
|
|
963
|
-
}
|
|
964
|
-
if (!movable.length) { dropPh.remove(); return; }
|
|
976
|
+
const failed = ids.map((id) => state.tasks.find((x) => x.id === id)).filter(Boolean)
|
|
977
|
+
.filter((t) => !movable.includes(t))
|
|
978
|
+
.map((t) => ({ key: t.key, why: moveBlocked(t.status, status, { manual: taskIsNoclaude(t) }) || tr('Claude moves these columns itself') }));
|
|
979
|
+
if (!movable.length) { dropPh.remove(); sayRefusedMany('Not moved', failed); return; }
|
|
965
980
|
|
|
966
981
|
const draggedSet = new Set(movable.map((t) => t.id));
|
|
967
982
|
const cards = [...body.querySelectorAll('.card')].filter((c) => !draggedSet.has(Number(c.dataset.id)));
|
|
@@ -978,10 +993,12 @@ async function onDropGroup(e, status, body) {
|
|
|
978
993
|
dropSends = true;
|
|
979
994
|
for (let i = 0; i < movable.length; i++) {
|
|
980
995
|
if (fixedOrder && movable[i].status === status) continue;
|
|
981
|
-
await api('PATCH', `/api/tasks/${seg(movable[i].id)}`, fixedOrder ? { status } : { status, position: prev + step * (i + 1) });
|
|
996
|
+
try { await api('PATCH', `/api/tasks/${seg(movable[i].id)}`, fixedOrder ? { status } : { status, position: prev + step * (i + 1) }, { quiet: true }); }
|
|
997
|
+
catch (e) { failed.push({ key: movable[i].key, e }); }
|
|
982
998
|
}
|
|
983
999
|
clearSelection();
|
|
984
1000
|
await refresh();
|
|
1001
|
+
sayRefusedMany('Not moved', failed);
|
|
985
1002
|
}
|
|
986
1003
|
|
|
987
1004
|
export function buildSelect(host, opts) {
|
package/public/js/chat.js
CHANGED
|
@@ -86,7 +86,8 @@ function stepRow(it, working = false) {
|
|
|
86
86
|
const tail = it.running ? '' : [it.wrote ? timesWords(it.wrote, it.ms) : dur(it.ms), it.error ? '' : (it.ms !== undefined ? '✓' : '')].filter(Boolean).join(' ');
|
|
87
87
|
const steps = it.helper ? `<span class="chat-step-n">${it.helper.steps} ${esc(plural(it.helper.steps, 'step', 'steps'))}</span>` : '';
|
|
88
88
|
const since = it.running ? clockOf(it.since || it.at) : '';
|
|
89
|
-
const bg = it.running ? `<span class="chat-bg">${working ? '<span class="chat-pulse"></span>' : ''}${esc(since ? `${tr('in the background since')} ${since}` : tr('in the background'))}</span>`
|
|
89
|
+
const bg = it.running ? `<span class="chat-bg">${working ? '<span class="chat-pulse"></span>' : ''}${esc(since ? `${tr('in the background since')} ${since}` : tr('in the background'))}</span>`
|
|
90
|
+
: it.lost ? `<span class="chat-bg">${esc(tr('no word from it for over 2 h'))}</span>` : '';
|
|
90
91
|
const what = it.pic ? `<button type="button" class="chat-step-t chat-pic" data-pic="${esc(it.pic)}" title="${esc(tr('Open the picture'))}">${ic('image', 12)}<span class="chat-pic-t">${esc(it.text)}</span></button>`
|
|
91
92
|
: `<span class="chat-step-t">${esc(it.text)}</span>`;
|
|
92
93
|
return `<div class="chat-step${it.error ? ' err' : ''}${it.running ? ' running' : ''}" title="${esc(it.text)}"><span class="chat-step-k">${esc(tr(STEP_KIND[it.tool] || 'Tool'))}</span>`
|
|
@@ -125,10 +126,10 @@ const codeBtn = (what, icon, label) => `<button type="button" class="chat-code-b
|
|
|
125
126
|
// a block left open (cut off, or its fence missing) is not a command to run: its end is not known; nor is one longer than
|
|
126
127
|
const RUN_MAX = 8000;
|
|
127
128
|
const codeTools = (lang, closed, code = '') => `<span class="chat-code-tools">${codeBtn('copy', 'copy', 'Copy')}${closed && SHELL_LANGS.includes(lang) && code.length <= RUN_MAX ? codeBtn('run', 'terminal', 'Run in a Terminal window') : ''}</span>`;
|
|
128
|
-
function itemHTML(it) {
|
|
129
|
+
function itemHTML(it, shot = null) {
|
|
129
130
|
if (answered(it)) return answersHTML(it.answers);
|
|
130
131
|
return it.kind === 'stop' ? `<div class="chat-sep">${esc(tr('Stopped'))}</div>`
|
|
131
|
-
: it.kind === 'said' ?
|
|
132
|
+
: it.kind === 'said' ? wordsHTML(it.text, it.images, it.shots, shot, ' chat-mid')
|
|
132
133
|
: `<div class="chat-claude">${chatText(it.text, codeTools)}</div>`;
|
|
133
134
|
}
|
|
134
135
|
const CODE_DONE_MS = 1400; // the ✓ a button shows after it did its work
|
|
@@ -204,11 +205,11 @@ function runHTML(steps, id, open, { working, now = null, pic = null }) {
|
|
|
204
205
|
+ `${failed ? `<span class="chat-x">✗ ${failed}</span>` : ''}${ic('chevron', 11)}${thumb}</button>`
|
|
205
206
|
+ (open ? `<div class="chat-unfold chat-runbox" data-of="${esc(id)}">${steps.map((s) => stepRow(s, working)).join('')}</div>` : '');
|
|
206
207
|
}
|
|
207
|
-
function itemsHTML(items, at, { opened, working, fold = true, live = false, pic = null }) {
|
|
208
|
+
function itemsHTML(items, at, { opened, working, fold = true, live = false, pic = null, shot = null }) {
|
|
208
209
|
let out = '';
|
|
209
210
|
const runs = runsOf(items);
|
|
210
211
|
runs.forEach((r, i) => {
|
|
211
|
-
if (r.it) { out += itemHTML(r.it); return; }
|
|
212
|
+
if (r.it) { out += itemHTML(r.it, shot); return; }
|
|
212
213
|
if (!fold) { out += r.steps.map((s) => stepRow(s, working)).join(''); return; }
|
|
213
214
|
const id = `${at}|${i}`;
|
|
214
215
|
// Claude calls tools side by side: the one still unanswered may be any of the run's, not its last
|
|
@@ -265,28 +266,29 @@ export function carryOpened(turns, opened) {
|
|
|
265
266
|
}
|
|
266
267
|
const SHOT_W = 220; // a thumbnail of the person's picture at most (.chat-shot img)
|
|
267
268
|
const SHOT_H = 160;
|
|
269
|
+
function wordsHTML(text, images, shotList, shot, cls = '') {
|
|
270
|
+
const shots = shot && shotList?.length ? shotList : [];
|
|
271
|
+
// as many «[Image #N]» as pictures shown leave the words — the rest stay with the «📎» that counts them
|
|
272
|
+
let strip = shots.length;
|
|
273
|
+
const words = strip ? String(text || '').replace(/\[Image #\d+\]\s*/g, (x) => (strip-- > 0 ? '' : x)).trim() : text;
|
|
274
|
+
const unseen = (images || 0) - shots.length; // a picture the board cannot show is still counted
|
|
275
|
+
const pics = shots.length ? `<div class="chat-shots">${shots.map((x) => {
|
|
276
|
+
// the place is kept before the picture decodes: a thumbnail growing late pushed the turn being read
|
|
277
|
+
const k = x.w > 0 && x.h > 0 ? Math.min(1, SHOT_W / x.w, SHOT_H / x.h) : 0;
|
|
278
|
+
const size = k ? ` width="${Math.max(1, Math.round(x.w * k))}" height="${Math.max(1, Math.round(x.h * k))}"` : '';
|
|
279
|
+
return `<button type="button" class="chat-shot" data-shot="${esc(shot(x.ref))}" title="${esc(tr('Open the picture'))}"><img src="${esc(shot(x.ref))}" alt=""${size} decoding="async"></button>`;
|
|
280
|
+
}).join('')}</div>` : '';
|
|
281
|
+
const bubble = words || unseen > 0 ? `<div class="chat-bubble">${esc(words || '')}${unseen > 0 ? `<span class="chat-img">${ic('attach', 11)} ${unseen}</span>` : ''}</div>` : '';
|
|
282
|
+
return `<div class="chat-user${cls}${pics ? ' has-shots' : ''}">${pics}${bubble}</div>`;
|
|
283
|
+
}
|
|
268
284
|
export function turnHTML(t, { opened = new Set(), working = false, shot = null, pic = null, asking = false } = {}) {
|
|
269
285
|
let out = '';
|
|
270
286
|
if (t.compact) out += `<div class="chat-sep">${esc(tr('The conversation was compacted'))}</div>`;
|
|
271
|
-
if (t.user || t.images)
|
|
272
|
-
const shots = shot && t.shots?.length ? t.shots : [];
|
|
273
|
-
// as many «[Image #N]» as pictures shown leave the words — the rest stay with the «📎» that counts them
|
|
274
|
-
let strip = shots.length;
|
|
275
|
-
const words = strip ? String(t.user || '').replace(/\[Image #\d+\]\s*/g, (x) => (strip-- > 0 ? '' : x)).trim() : t.user;
|
|
276
|
-
const unseen = (t.images || 0) - shots.length; // a picture the board cannot show is still counted
|
|
277
|
-
const pics = shots.length ? `<div class="chat-shots">${shots.map((x) => {
|
|
278
|
-
// the place is kept before the picture decodes: a thumbnail growing late pushed the turn being read
|
|
279
|
-
const k = x.w > 0 && x.h > 0 ? Math.min(1, SHOT_W / x.w, SHOT_H / x.h) : 0;
|
|
280
|
-
const size = k ? ` width="${Math.max(1, Math.round(x.w * k))}" height="${Math.max(1, Math.round(x.h * k))}"` : '';
|
|
281
|
-
return `<button type="button" class="chat-shot" data-shot="${esc(shot(x.ref))}" title="${esc(tr('Open the picture'))}"><img src="${esc(shot(x.ref))}" alt=""${size} decoding="async"></button>`;
|
|
282
|
-
}).join('')}</div>` : '';
|
|
283
|
-
const bubble = words || unseen > 0 ? `<div class="chat-bubble">${esc(words || '')}${unseen > 0 ? `<span class="chat-img">${ic('attach', 11)} ${unseen}</span>` : ''}</div>` : '';
|
|
284
|
-
out += `<div class="chat-user${pics ? ' has-shots' : ''}">${pics}${bubble}</div>`;
|
|
285
|
-
}
|
|
287
|
+
if (t.user || t.images) out += wordsHTML(t.user, t.images, t.shots, shot);
|
|
286
288
|
const worked = t.ms ? `${esc(tr('Worked for'))} ${esc(dur(t.ms) || `${Math.round(t.ms / 1000)} ${tr('s')}`)}` : esc(tr('Done'));
|
|
287
289
|
if (!barred(t)) {
|
|
288
290
|
// a step waiting on a question or a permission is not run — Claude waits for the person (`asking`)
|
|
289
|
-
out += itemsHTML(t.items, t.at, { opened, working, live: working && Boolean(t.open) && !asking, pic });
|
|
291
|
+
out += itemsHTML(t.items, t.at, { opened, working, live: working && Boolean(t.open) && !asking, pic, shot });
|
|
290
292
|
return `<div class="chat-turn" data-at="${esc(t.at)}">${out}</div>`;
|
|
291
293
|
}
|
|
292
294
|
// background) folded all its text behind the bar, and the words on the screen went at the end of the turn
|
|
@@ -303,11 +305,11 @@ export function turnHTML(t, { opened = new Set(), working = false, shot = null,
|
|
|
303
305
|
.filter(Boolean).map((x) => `<span class="chat-chip">${x}</span>`).join('');
|
|
304
306
|
out += `<button type="button" class="chat-done${open ? ' open' : ''}" data-fold="${esc(id)}" aria-expanded="${open}">${ic('chevron', 11)}${ic('check', 11)}<span class="chat-done-t">${worked}</span>`
|
|
305
307
|
+ `<span class="chat-chips">${failed ? `<span class="chat-chip chat-x">✗ ${failed}</span>` : ''}${chips}</span></button>`;
|
|
306
|
-
if (open) out += `<div class="chat-log-in chat-unfold" data-of="${esc(id)}">${itemsHTML(log, t.at, { opened, working, fold: false })}</div>`;
|
|
308
|
+
if (open) out += `<div class="chat-log-in chat-unfold" data-of="${esc(id)}">${itemsHTML(log, t.at, { opened, working, fold: false, shot })}</div>`;
|
|
307
309
|
} else {
|
|
308
310
|
out += `<div class="chat-worked">${ic('check', 11)} ${worked}</div>`;
|
|
309
311
|
}
|
|
310
|
-
out += t.items.filter(said).map(itemHTML).join('');
|
|
312
|
+
out += t.items.filter(said).map((it) => itemHTML(it, shot)).join('');
|
|
311
313
|
if (files.length) out += filesHTML(files, t.at, opened);
|
|
312
314
|
if (last) out += itemHTML(last);
|
|
313
315
|
return `<div class="chat-turn" data-at="${esc(t.at)}">${out}</div>`;
|
package/public/js/core.js
CHANGED
|
@@ -1479,6 +1479,20 @@ const I18N_RU = {
|
|
|
1479
1479
|
'Not saved: only the owner of the board can do this.': 'Не сохранено: это может только владелец доски.',
|
|
1480
1480
|
'only the owner of the board can do this': 'это может только владелец доски',
|
|
1481
1481
|
'a guest starts Claude only inside the board': 'гость запускает Claude только внутри доски',
|
|
1482
|
+
'tmux did not answer in time — try again in a few seconds': 'tmux не ответил вовремя — повтори через несколько секунд',
|
|
1483
|
+
'no word from it for over 2 h': 'нет вестей больше 2 ч',
|
|
1484
|
+
'the CLAUDE.md of this project is within the norms now — there is nothing to fix': 'CLAUDE.md этого проекта уже в норме — чинить нечего',
|
|
1485
|
+
"tmux was updated while the board's terminals still run on the old one, and the new tmux cannot reach them. Close them with the command in Settings → Errors; the conversations are kept — «Continue» brings each one back.": 'tmux обновили, пока терминалы доски работают на старом, и новый tmux до них не достаёт. Закрой их командой из «Настроек → Ошибки»; разговоры сохранятся — «Продолжить» вернёт каждый.',
|
|
1486
|
+
'tmux was updated under the running terminals of the board — close them, the command is in Settings → Errors': 'tmux обновили под работающими терминалами доски — закрой их, команда в «Настройках → Ошибки»',
|
|
1487
|
+
'tmux was updated under the running terminals of the board': 'tmux обновили под работающими терминалами доски',
|
|
1488
|
+
'the project has no CLAUDE.md of its own any more': 'у проекта больше нет своего CLAUDE.md',
|
|
1489
|
+
'Fix': 'Починить',
|
|
1490
|
+
'Open the task «Shrink CLAUDE.md» and start Claude on it — only while nobody works in the folder of the project': 'Завести задачу «Ужать CLAUDE.md» и сразу запустить на ней Claude — только пока в папке проекта никто не работает',
|
|
1491
|
+
'Claude works in the folder of this project — fix its CLAUDE.md once that work is over.': 'В папке этого проекта сейчас работает Claude — почини его CLAUDE.md, когда эта работа закончится.',
|
|
1492
|
+
'Tasks in work': 'Задачи в работе',
|
|
1493
|
+
'Claude sessions at work there': 'Сессий Claude работает там',
|
|
1494
|
+
'The last fix of this CLAUDE.md waits for your review': 'Прошлая починка этого CLAUDE.md ждёт твоей проверки',
|
|
1495
|
+
'Could not open the task': 'Не удалось завести задачу',
|
|
1482
1496
|
'this board has no such label': 'на этой доске такой метки нет',
|
|
1483
1497
|
'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
1498
|
'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': 'у этого устройства больше нет ключа доски, возможно, его сменили — скопируй набранное, обнови страницу и введи ключ',
|
package/public/js/drawer.js
CHANGED
|
@@ -8,7 +8,7 @@ import { getSetting, openSettingsModal, plural, setClaudeView } from './settings
|
|
|
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, createCommentKeys, createTurns, createUnsentComments, createUnsentEdits, imageTooLarge, pullExternal, refusalLine, refusalText, refusedFields, resendDelay } from './unsaved.js';
|
|
11
|
+
import { canRetry, changedFields, createCommentKeys, createRefusedTyped, 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';
|
|
@@ -601,6 +601,7 @@ async function pollTerminal() {
|
|
|
601
601
|
let r;
|
|
602
602
|
try { r = await api('GET', `/api/tasks/${seg(key)}/terminal${qs ? `?${qs}` : ''}`, undefined, { quiet: true }); } catch { return; }
|
|
603
603
|
if (n !== termReq || key !== termKey || key !== state.drawerKey) return;
|
|
604
|
+
if (r.unknown) return;
|
|
604
605
|
if (!r.same) {
|
|
605
606
|
r = wholeAnswer(r, termBase, termSeen);
|
|
606
607
|
if (!r) { termSeen = null; return; } // not against what this card has: the next look gets it whole
|
|
@@ -2468,6 +2469,7 @@ export async function doAutosave() {
|
|
|
2468
2469
|
}
|
|
2469
2470
|
|
|
2470
2471
|
const unsentEdits = createUnsentEdits();
|
|
2472
|
+
const refusedTyped = createRefusedTyped();
|
|
2471
2473
|
const editInTurn = createTurns();
|
|
2472
2474
|
function queueTaskEdit(key, fields) {
|
|
2473
2475
|
unsentEdits.fail(key, fields);
|
|
@@ -2479,7 +2481,8 @@ async function resendTaskEdit(key) {
|
|
|
2479
2481
|
const fields = { ...queued };
|
|
2480
2482
|
const here = key === state.drawerKey && drawerShown() && drawerSnapshot && !drawerLocked;
|
|
2481
2483
|
if (here) { clearTimeout(autosaveTimer); autosaveTimer = null; }
|
|
2482
|
-
const
|
|
2484
|
+
const form = here ? formToSave() : null;
|
|
2485
|
+
const dirty = here ? refusedTyped.without(key, changedFields(form, drawerSnapshot), form) : {};
|
|
2483
2486
|
if (descConflicts.has(key)) { delete dirty.description; delete fields.description; }
|
|
2484
2487
|
const sent = { ...fields, ...dirty };
|
|
2485
2488
|
// base the last word won, and a `kb desc` made while offline was erased when the connection came back
|
|
@@ -2492,6 +2495,7 @@ async function resendTaskEdit(key) {
|
|
|
2492
2495
|
const back = refusalText(e).gone ? [] : refusedFields(e, sent);
|
|
2493
2496
|
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
2497
|
unsentEdits.supersede(key, back);
|
|
2498
|
+
refusedTyped.keep(key, back, sent);
|
|
2495
2499
|
if (here && state.drawerKey === key) { takeBackRefused(back, e); sayFieldRefused(key, e); }
|
|
2496
2500
|
else styledAlert(`${key}: ${refusalLine('The change is not saved', e, tr)}`);
|
|
2497
2501
|
const left = { ...dirty };
|
|
@@ -2515,7 +2519,7 @@ async function resendTaskEdit(key) {
|
|
|
2515
2519
|
unsentEdits.settle(key, fields);
|
|
2516
2520
|
unsentEdits.settle(key, dirty);
|
|
2517
2521
|
if (here && state.drawerKey === key && drawerSnapshot) Object.assign(drawerSnapshot, dirty);
|
|
2518
|
-
if (here && state.drawerKey === key && refusedNote !== key) showSaveNote(null);
|
|
2522
|
+
if (here && state.drawerKey === key && refusedNote !== key && !refusedTyped.has(key)) showSaveNote(null);
|
|
2519
2523
|
}
|
|
2520
2524
|
const unsentCreates = new Map();
|
|
2521
2525
|
function editRefusedText(e) {
|
|
@@ -2586,8 +2590,8 @@ async function doAutosaveOnce() {
|
|
|
2586
2590
|
const full = formToSave();
|
|
2587
2591
|
const { title = '', description, ...common } = full;
|
|
2588
2592
|
if (state.drawerKey) {
|
|
2589
|
-
const patch = drawerSnapshot ? changedFields(full, drawerSnapshot) : full;
|
|
2590
2593
|
const key = state.drawerKey;
|
|
2594
|
+
const patch = drawerSnapshot ? refusedTyped.without(key, changedFields(full, drawerSnapshot), full) : full;
|
|
2591
2595
|
if (descConflicts.has(key)) delete patch.description;
|
|
2592
2596
|
if (!Object.keys(patch).length) return;
|
|
2593
2597
|
const body = 'description' in patch && drawerSnapshot ? { ...patch, description_base: descBaseOf(key) } : patch;
|
|
@@ -2606,6 +2610,7 @@ async function doAutosaveOnce() {
|
|
|
2606
2610
|
if (canRetry(e)) queueTaskEdit(key, patch);
|
|
2607
2611
|
// says it); the card moved on to another task while the request went: a refusal is said with the task's key
|
|
2608
2612
|
if (!e?.offline && !r.gone) {
|
|
2613
|
+
refusedTyped.keep(key, back, patch);
|
|
2609
2614
|
if (key === state.drawerKey) takeBackRefused(back, e);
|
|
2610
2615
|
if (restQueued) queueTaskEdit(key, rest); // as data: the card may close before it is sent
|
|
2611
2616
|
if (key === state.drawerKey) { if (back.length) sayFieldRefused(key, e); else showSaveNote(editRefusedText(e)); }
|
|
@@ -2618,7 +2623,7 @@ async function doAutosaveOnce() {
|
|
|
2618
2623
|
if ('description' in body) descSent(key, patch.description, body.description_base);
|
|
2619
2624
|
}
|
|
2620
2625
|
if ('description' in body) queuedBase.delete(key); // the description got there: nothing waits from that base
|
|
2621
|
-
if (key === state.drawerKey) showSaveNote(null);
|
|
2626
|
+
if (key === state.drawerKey && !refusedTyped.has(key)) showSaveNote(null);
|
|
2622
2627
|
if (unsentEdits.supersede(key, Object.keys(patch))) dropUnsaved(`task:${key}`);
|
|
2623
2628
|
const { description: mine, ...rest } = patch;
|
|
2624
2629
|
if (drawerSnapshot && state.drawerKey === key) Object.assign(drawerSnapshot, saved?.description_conflict ? rest : patch);
|
|
@@ -7,6 +7,8 @@ export function fileState(f, norms) {
|
|
|
7
7
|
return 'ok';
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
export const fixable = (main, norms) => ['heavy', 'long'].includes(fileState(main, norms));
|
|
11
|
+
|
|
10
12
|
const RANK = { heavy: 0, long: 1, ok: 2 };
|
|
11
13
|
|
|
12
14
|
/** The projects split for the section: with their own CLAUDE.md (worst first, then the most loaded at the start), without
|
package/public/js/health.js
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
import { $, HEALTH, LANG, api, esc, ic, state, tr } from './core.js';
|
|
3
3
|
import { keepFocus } from './now.js';
|
|
4
4
|
import { plural } from './settings.js';
|
|
5
|
-
import { selectProject } from './sidebar.js';
|
|
6
|
-
import {
|
|
5
|
+
import { selectProject, styledAlert, styledConfirm } from './sidebar.js';
|
|
6
|
+
import { refusalLine } from './unsaved.js';
|
|
7
|
+
import { openDrawer } from './drawer.js';
|
|
8
|
+
import { launchInClaude, taskPromptText } from './sse.js';
|
|
9
|
+
import { fileState, fixable, healthCounts, healthGroups, startTotal } from './health-rules.js';
|
|
7
10
|
|
|
8
11
|
const FRESH_MS = 30000;
|
|
9
12
|
const DOCS_URL = 'https://code.claude.com/docs/en/memory';
|
|
@@ -48,13 +51,16 @@ function projectHTML(p, d) {
|
|
|
48
51
|
: `<span class="hl-size muted">${esc(tr('no CLAUDE.md of its own'))}</span>`;
|
|
49
52
|
const start = `<span class="hl-start${total.over ? ' hl-heavy' : ''}" title="${esc(tr('Everything a session reads when it starts in this folder, with your own files'))}">`
|
|
50
53
|
+ `${esc(tr('at the start'))}: ${esc(num(total.chars))} ${esc(plural(total.chars, 'character', 'characters'))}</span>`;
|
|
54
|
+
const fix = p.main && fixable(p.main, norms)
|
|
55
|
+
? `<button type="button" class="btn-ghost hl-fix" data-slug="${esc(p.slug)}" title="${esc(tr('Open the task «Shrink CLAUDE.md» and start Claude on it — only while nobody works in the folder of the project'))}">${esc(tr('Fix'))}</button>`
|
|
56
|
+
: '';
|
|
51
57
|
const files = [...p.files, ...p.nested];
|
|
52
58
|
const body = files.length
|
|
53
59
|
? files.map((f) => fileHTML(f, norms)).join('')
|
|
54
60
|
: `<div class="hl-file muted">${esc(tr('Claude Code reads nothing of this project at the start but your own files.'))}</div>`;
|
|
55
61
|
return `<details class="hl-proj" data-slug="${esc(p.slug)}"${opened.has(p.slug) ? ' open' : ''}>`
|
|
56
62
|
+ `<summary class="hl-row">${ic('chevron', 13)}<span class="hl-main"><a class="hl-name" href="#" data-slug="${esc(p.slug)}">${esc(p.name)}</a>`
|
|
57
|
-
+ `<span class="hl-what">${what}</span></span>${start}</summary>`
|
|
63
|
+
+ `<span class="hl-what">${what}</span></span>${start}${fix}</summary>`
|
|
58
64
|
+ `<div class="hl-files">${body}</div></details>`;
|
|
59
65
|
}
|
|
60
66
|
|
|
@@ -109,6 +115,39 @@ function paint(d) {
|
|
|
109
115
|
a.onclick = (e) => { e.preventDefault(); e.stopPropagation(); selectProject(a.dataset.slug); };
|
|
110
116
|
});
|
|
111
117
|
box.querySelector('.hl-again').onclick = (e) => { e.currentTarget.disabled = true; renderHealth({ force: true }); };
|
|
118
|
+
box.querySelectorAll('.hl-fix').forEach((b) => {
|
|
119
|
+
b.onclick = (e) => { e.preventDefault(); e.stopPropagation(); fixProject(b.dataset.slug, b); }; // a click in <summary> folds it
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const fixing = new Set(); // a second press while the first one opens and starts the task
|
|
124
|
+
/** The words of a refusal «Claude works there»: what holds the folder. */
|
|
125
|
+
export function busyWords(b) {
|
|
126
|
+
const parts = [tr('Claude works in the folder of this project — fix its CLAUDE.md once that work is over.')];
|
|
127
|
+
if (b.keys?.length) parts.push(`${tr('Tasks in work')}: ${b.keys.join(', ')}.`);
|
|
128
|
+
if (b.sessions) parts.push(`${tr('Claude sessions at work there')}: ${b.sessions}.`);
|
|
129
|
+
return parts.join(' ');
|
|
130
|
+
}
|
|
131
|
+
async function fixProject(slug, btn) {
|
|
132
|
+
if (!slug || fixing.has(slug)) return;
|
|
133
|
+
fixing.add(slug);
|
|
134
|
+
btn.disabled = true;
|
|
135
|
+
try {
|
|
136
|
+
let r;
|
|
137
|
+
try { r = await api('POST', '/api/health/fix', { project: slug }, { quiet: true }); } catch (e) {
|
|
138
|
+
const b = e.body || {};
|
|
139
|
+
if (b.reason === 'busy') styledAlert(busyWords(b));
|
|
140
|
+
else if (b.reason === 'review' && b.key) {
|
|
141
|
+
styledConfirm(`${tr('The last fix of this CLAUDE.md waits for your review')}: ${b.key}.`, { okLabel: `${tr('Open')} ${b.key}` })
|
|
142
|
+
.then((ok) => { if (ok) openDrawer(b.key); });
|
|
143
|
+
} else styledAlert(refusalLine('Could not open the task', e, tr));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
await launchInClaude({ text: taskPromptText(r.key), project: slug, keys: [r.key], btn });
|
|
147
|
+
} finally {
|
|
148
|
+
fixing.delete(slug);
|
|
149
|
+
btn.disabled = false;
|
|
150
|
+
}
|
|
112
151
|
}
|
|
113
152
|
|
|
114
153
|
let seq = 0;
|
package/public/js/now-terms.js
CHANGED
|
@@ -208,7 +208,7 @@ async function look(c) {
|
|
|
208
208
|
let r;
|
|
209
209
|
try { r = await api('GET', `/api/tasks/${seg(c.key)}/terminal?${q}`, undefined, { quiet: true }); } catch { return; }
|
|
210
210
|
if (n2 !== c.req) return; // a newer look has answered already
|
|
211
|
-
if (r.same) return;
|
|
211
|
+
if (r.same || r.unknown) return;
|
|
212
212
|
r = wholeAnswer(r, c.base, c.seen);
|
|
213
213
|
if (!r) { c.seen = null; return; } // not against what this column has: the next look gets it whole
|
|
214
214
|
c.seen = r.v || null;
|
package/public/js/project.js
CHANGED
|
@@ -227,7 +227,7 @@ async function renderDocs(p) {
|
|
|
227
227
|
const row = (opts) => `<div class="ps-doc${opts.missing ? ' ps-doc-missing' : ''}">
|
|
228
228
|
<span class="ps-doc-ic">${ic(opts.icon, 13)}</span>
|
|
229
229
|
<span class="ps-doc-name">${esc(opts.title)}${opts.tag ? `<span class="ps-doc-tag">${esc(opts.tag)}</span>` : ''}</span>
|
|
230
|
-
<span class="ps-doc-path" title="${esc(opts.path || '')}">${esc(opts.path || tr('not there'))}</span>
|
|
230
|
+
<span class="ps-doc-path" title="${esc(opts.path || '')}">${esc(opts.path || (info.paths_hidden ? '' : tr('not there')))}</span>
|
|
231
231
|
<span class="ps-doc-act">
|
|
232
232
|
${opts.missing ? '' : `<button class="btn-icon ps-doc-open" data-open="${esc(opts.open)}" title="${tr('Open')}">${ic('eye', 13)}</button>`}
|
|
233
233
|
${opts.path ? `<button class="btn-icon ps-doc-copy" data-copy="${esc(opts.path)}" title="${tr('Copy the path')}">${ic('copy', 13)}</button>` : ''}
|
|
@@ -91,6 +91,7 @@ const FAIL_WORDS = {
|
|
|
91
91
|
manual: 'a task done by hand cannot be given to Claude',
|
|
92
92
|
running: 'a Claude of the board already works on these tasks',
|
|
93
93
|
'tmux-slow': 'tmux did not answer in time',
|
|
94
|
+
'tmux-broken': 'tmux was updated under the running terminals of the board',
|
|
94
95
|
};
|
|
95
96
|
/** The words for an automation's `error` — English, the Russian is in I18N_RU; null for a line that names no reason it knows. */
|
|
96
97
|
export const failWord = (error) => FAIL_WORDS[String(error ?? '').match(/^([a-z-]+): /)?.[1]] ?? null;
|
package/public/js/review.js
CHANGED
|
@@ -147,7 +147,7 @@ export async function renderReview() {
|
|
|
147
147
|
await Promise.all(groups.filter((g) => g.running).map(async (g) => {
|
|
148
148
|
try {
|
|
149
149
|
const t = await api('GET', `/api/tasks/${encodeURIComponent(g.running.key)}/terminal`, undefined, { quiet: true });
|
|
150
|
-
g.terminal = { session: t.session || null, dead: Boolean(t.dead), question: Boolean(t.question) };
|
|
150
|
+
if (!t.unknown) g.terminal = { session: t.session || null, dead: Boolean(t.dead), question: Boolean(t.question) };
|
|
151
151
|
} catch { /* no answer: the column says what it can */ }
|
|
152
152
|
}));
|
|
153
153
|
if (state.slug !== REVIEW || my !== seq) return;
|
package/public/js/sse.js
CHANGED
|
@@ -257,7 +257,7 @@ function openTaskFromNotification(key, project) {
|
|
|
257
257
|
function maybeWaitingAlert(changes) {
|
|
258
258
|
// it tells its Claude to go on (`retrying`): that rang for a blink of the network the person need not see
|
|
259
259
|
const waiting = (changes || []).filter((c) => (c.claude === 'idle' && c.broke === 'stuck' && c.fromBroke !== 'stuck')
|
|
260
|
-
|| (!c.broke && (c.from === 'busy' || c.from === 'background') && (c.claude === 'waiting' || c.claude === 'idle'))
|
|
260
|
+
|| (!c.broke && c.status !== 'ready' && (c.from === 'busy' || c.from === 'background') && (c.claude === 'waiting' || c.claude === 'idle'))
|
|
261
261
|
|| (c.claude === 'limit' && c.from !== 'limit'));
|
|
262
262
|
if (!waiting.length) return new Set();
|
|
263
263
|
playReviewSound();
|
|
@@ -649,6 +649,7 @@ const BOARD_WHY = {
|
|
|
649
649
|
'no-claude': 'Claude Code (the claude command) is not found on this computer — install it, or pick another way to start tasks in Settings.',
|
|
650
650
|
'too-long': "The prompt is too long for the board's terminal — start it in the Terminal or the Claude app (Settings), or shorten it.",
|
|
651
651
|
'tmux-slow': 'tmux did not answer in time, so the board cannot tell whether a Claude already works on these tasks — press ▶ again in a few seconds.',
|
|
652
|
+
'tmux-broken': "tmux was updated while the board's terminals still run on the old one, and the new tmux cannot reach them. Close them with the command in Settings → Errors; the conversations are kept — «Continue» brings each one back.",
|
|
652
653
|
};
|
|
653
654
|
/** Continue the board's session `session` of the task `key` in its terminal; true — started. */
|
|
654
655
|
export async function resumeOnBoard({ key, session, btn, again = false }) {
|
package/public/js/unsaved.js
CHANGED
|
@@ -73,6 +73,34 @@ export function refusedFields(e, sent) {
|
|
|
73
73
|
return 'status' in sent ? ['status'] : [];
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/* A refused title, description or priority stays in the form as typed — the card says it is not saved — but goes again only
|
|
77
|
+
once it is changed: left differing from the snapshot, every resend and autosave took it with the rest, the server refused
|
|
78
|
+
the whole PATCH again, and the resend called itself — two thousand requests in ten seconds while the card stayed open,
|
|
79
|
+
and the rest of the edit never got there. Labels and status go back to the server's instead (takeBackRefused in
|
|
80
|
+
drawer.js). Pure. */
|
|
81
|
+
export function createRefusedTyped() {
|
|
82
|
+
const byKey = new Map(); // task key → { field: the value the server refused }
|
|
83
|
+
return {
|
|
84
|
+
keep(key, fields, sent) {
|
|
85
|
+
const kept = byKey.get(key) || {};
|
|
86
|
+
for (const f of fields) if (f !== 'labels' && f !== 'status' && f in sent) kept[f] = sent[f];
|
|
87
|
+
if (Object.keys(kept).length) byKey.set(key, kept);
|
|
88
|
+
},
|
|
89
|
+
/** The edit without what the server refused and the form still holds unchanged; a field changed since goes again. */
|
|
90
|
+
without(key, patch, form) {
|
|
91
|
+
const kept = byKey.get(key);
|
|
92
|
+
if (!kept) return patch;
|
|
93
|
+
for (const f of Object.keys(kept)) {
|
|
94
|
+
if (form[f] !== kept[f]) delete kept[f];
|
|
95
|
+
else delete patch[f];
|
|
96
|
+
}
|
|
97
|
+
if (!Object.keys(kept).length) byKey.delete(key);
|
|
98
|
+
return patch;
|
|
99
|
+
},
|
|
100
|
+
has: (key) => byKey.has(key),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
76
104
|
export const RESEND_MS = 3000;
|
|
77
105
|
export const RESEND_MAX_MS = 60000;
|
|
78
106
|
/** How long to wait before the next try, `n` — how many tries in a row already failed. */
|
|
@@ -234,6 +262,10 @@ const KNOWN = [
|
|
|
234
262
|
[/^the project has no folder on this computer — the command would run/, 'the project has no folder on this computer — set it in the project, or copy the command'],
|
|
235
263
|
[/^the terminal did not open/, 'the terminal did not open — copy the command instead; the details are in Settings → Errors'],
|
|
236
264
|
[/^a guest starts Claude inside the board only$/, 'a guest starts Claude only inside the board'],
|
|
265
|
+
[/^the project has no CLAUDE\.md of its own$/, 'the project has no CLAUDE.md of its own any more'],
|
|
266
|
+
[/^the CLAUDE\.md of this project is within the norms/, 'the CLAUDE.md of this project is within the norms now — there is nothing to fix'],
|
|
267
|
+
[/^tmux did not answer in time — try again$/, 'tmux did not answer in time — try again in a few seconds'],
|
|
268
|
+
[/^tmux was updated while the board's terminals still run on the old one/, 'tmux was updated under the running terminals of the board — close them, the command is in Settings → Errors'],
|
|
237
269
|
[/^unknown labels: /, 'this board has no such label'],
|
|
238
270
|
];
|
|
239
271
|
export function refusalText(e) {
|