crewforth 3.5.0-rc.1 → 3.5.0-rc.2
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 +32 -0
- package/README.md +1 -1
- package/VERSION +1 -1
- package/kit/CLAUDE.md +1 -1
- package/kit/eval/lib/statusline.sh +22 -5
- package/kit/eval/lib/todo.awk +167 -0
- package/kit/eval/lib/todo.sh +120 -0
- package/kit/eval/smoke-test.sh +60 -2
- package/kit/hooks/prompt-approval.sh +5 -5
- package/kit/skills/crew-plan/SKILL.md +2 -1
- package/kit/skills/crew-todo/SKILL.md +31 -0
- package/kit/skills/spec-planning/SKILL.md +9 -2
- package/kit/studio/web/agent-spend.js +32 -0
- package/kit/studio/web/app.js +245 -236
- package/kit/studio/web/approvals.js +14 -13
- package/kit/studio/web/ask.js +10 -9
- package/kit/studio/web/canvas.js +56 -54
- package/kit/studio/web/chat.js +41 -41
- package/kit/studio/web/convo.js +21 -18
- package/kit/studio/web/dock.js +20 -19
- package/kit/studio/web/graph-plan.js +62 -22
- package/kit/studio/web/i18n-tr.js +760 -0
- package/kit/studio/web/i18n.js +79 -0
- package/kit/studio/web/index.html +1 -0
- package/kit/studio/web/inspect.js +27 -24
- package/kit/studio/web/list-plan.js +16 -15
- package/kit/studio/web/list.js +6 -5
- package/kit/studio/web/liveness.js +11 -9
- package/kit/studio/web/map-plan.js +33 -29
- package/kit/studio/web/map.js +74 -73
- package/kit/studio/web/models-plan.js +47 -42
- package/kit/studio/web/models.js +21 -20
- package/kit/studio/web/names.js +13 -10
- package/kit/studio/web/nav.js +34 -33
- package/kit/studio/web/newsession.js +25 -24
- package/kit/studio/web/style.css +2 -0
- package/kit/studio/web/theme.js +5 -2
- package/kit/studio/web/timeline-plan.js +19 -18
- package/kit/studio/web/timeline.js +23 -22
- package/kit/studio/web/usage.js +36 -32
- package/package.json +1 -1
package/kit/studio/web/app.js
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
import { liveness, offlineNote, staleServerNote } from './liveness.js';
|
|
18
18
|
import { attention, settle, AUTO } from './graph-plan.js';
|
|
19
19
|
import { summaryRows, usageLine, costNote } from './usage.js';
|
|
20
|
+
import { agentSpendRows } from './agent-spend.js';
|
|
20
21
|
import { ServerClock, queue, settled, terminalWait, OUTCOME_WORD } from './approvals.js';
|
|
21
22
|
import { Dock } from './dock.js';
|
|
22
23
|
import { NewSession } from './newsession.js';
|
|
@@ -32,6 +33,7 @@ import { shortcutOf } from './keys.js';
|
|
|
32
33
|
import { qrSvg } from './qr.js';
|
|
33
34
|
import { SESSION_MODES } from './convo.js';
|
|
34
35
|
import { fmtDuration, fmtTokens, tiles, timeLine, rightNow, skillsOf, delegatedBy, reportOf, backgroundLines, modelLines, TABS as INSPECTOR_TABS, TAB_WORD } from './inspect.js';
|
|
36
|
+
import { t, numLocale, translateDom, initLang } from './i18n.js';
|
|
35
37
|
|
|
36
38
|
const FLEET_POLL_MS = 2000;
|
|
37
39
|
const SESSION_POLL_MS = 5000;
|
|
@@ -129,7 +131,7 @@ async function statusError(r) {
|
|
|
129
131
|
let b = null; try { b = await r.json(); } catch { /* not JSON */ }
|
|
130
132
|
if (b?.expired) showExpired();
|
|
131
133
|
}
|
|
132
|
-
if (r.status === 403 && transcriptsHidden) return new Error('Transcripts are hidden on this link');
|
|
134
|
+
if (r.status === 403 && transcriptsHidden) return new Error(t('Transcripts are hidden on this link'));
|
|
133
135
|
return new Error(`HTTP ${r.status}`);
|
|
134
136
|
}
|
|
135
137
|
|
|
@@ -139,8 +141,8 @@ function showExpired() {
|
|
|
139
141
|
box.id = 'expired';
|
|
140
142
|
box.className = 'expired';
|
|
141
143
|
box.setAttribute('role', 'alert');
|
|
142
|
-
const h = document.createElement('h1'); h.textContent = 'Session expired';
|
|
143
|
-
const p = document.createElement('p'); p.textContent = 'Viewer links last eight hours. Ask for a new one on the machine running Studio.';
|
|
144
|
+
const h = document.createElement('h1'); h.textContent = t('Session expired');
|
|
145
|
+
const p = document.createElement('p'); p.textContent = t('Viewer links last eight hours. Ask for a new one on the machine running Studio.');
|
|
144
146
|
box.append(h, p);
|
|
145
147
|
document.body.append(box);
|
|
146
148
|
}
|
|
@@ -161,6 +163,9 @@ let themeStorage = { getItem: () => null, setItem: () => {} };
|
|
|
161
163
|
try { themeStorage = localStorage; } catch { /* blocked storage */ }
|
|
162
164
|
const relabelTheme = initTheme(document.documentElement, el.theme, themeStorage, () => systemLight.matches);
|
|
163
165
|
systemLight.addEventListener('change', relabelTheme);
|
|
166
|
+
// The language: index.html is written in English and put into the chosen one here, before anything reads it.
|
|
167
|
+
translateDom(document.body);
|
|
168
|
+
initLang(document.getElementById('lang'), themeStorage, () => location.reload());
|
|
164
169
|
|
|
165
170
|
/* --------------------------------------------------------------- panels
|
|
166
171
|
Three columns and two dividers. Each width is the user's, remembered, and
|
|
@@ -256,7 +261,7 @@ const inspectorSplit = document.createElement('div');
|
|
|
256
261
|
inspectorSplit.className = 'resizer inspector-split';
|
|
257
262
|
inspectorSplit.setAttribute('role', 'separator');
|
|
258
263
|
inspectorSplit.setAttribute('aria-orientation', 'vertical');
|
|
259
|
-
inspectorSplit.setAttribute('aria-label', 'Resize inspector');
|
|
264
|
+
inspectorSplit.setAttribute('aria-label', t('Resize inspector'));
|
|
260
265
|
inspectorSplit.tabIndex = 0;
|
|
261
266
|
dragPanel(inspectorSplit, 'inspector', 'right');
|
|
262
267
|
|
|
@@ -271,7 +276,7 @@ async function toggleFullscreen() {
|
|
|
271
276
|
else await document.documentElement.requestFullscreen();
|
|
272
277
|
} catch (e) {
|
|
273
278
|
// Denied by policy, or unsupported. Say so rather than appear inert.
|
|
274
|
-
el.foot.textContent =
|
|
279
|
+
el.foot.textContent = t('full screen refused: {reason}', { reason: e?.message ?? e });
|
|
275
280
|
}
|
|
276
281
|
}
|
|
277
282
|
|
|
@@ -280,16 +285,16 @@ el.fullscreen.addEventListener('click', toggleFullscreen);
|
|
|
280
285
|
document.addEventListener('fullscreenchange', () => {
|
|
281
286
|
const on = Boolean(document.fullscreenElement);
|
|
282
287
|
el.fullscreen.classList.toggle('on', on);
|
|
283
|
-
el.fullscreen.title = on ? 'Leave full screen (f or Esc)' : 'Full screen (f)';
|
|
284
|
-
el.fullscreen.setAttribute('aria-label', on ? 'Leave full screen' : 'Full screen');
|
|
288
|
+
el.fullscreen.title = on ? t('Leave full screen (f or Esc)') : t('Full screen (f)');
|
|
289
|
+
el.fullscreen.setAttribute('aria-label', on ? t('Leave full screen') : t('Full screen'));
|
|
285
290
|
canvas.fitIfUntouched();
|
|
286
291
|
});
|
|
287
292
|
|
|
288
293
|
// `f` toggles it, unless something is being typed into.
|
|
289
294
|
document.addEventListener('keydown', (e) => {
|
|
290
295
|
if (e.key !== 'f' || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
291
|
-
const
|
|
292
|
-
if (
|
|
296
|
+
const tg = e.target;
|
|
297
|
+
if (tg && (tg.tagName === 'INPUT' || tg.tagName === 'TEXTAREA' || tg.isContentEditable)) return;
|
|
293
298
|
e.preventDefault();
|
|
294
299
|
toggleFullscreen();
|
|
295
300
|
});
|
|
@@ -406,24 +411,23 @@ async function openOwned(sessionId) {
|
|
|
406
411
|
async function continueHere(button, from = current) {
|
|
407
412
|
if (!from) return;
|
|
408
413
|
button.disabled = true;
|
|
409
|
-
button.textContent = 'Continuing…';
|
|
414
|
+
button.textContent = t('Continuing…');
|
|
410
415
|
try {
|
|
411
416
|
const r = await chat.start({ cwd: projectsData?.cwd ?? null, permissionMode: 'plan', resume: from });
|
|
412
417
|
if (!r.ok) {
|
|
413
|
-
el.foot.textContent =
|
|
418
|
+
el.foot.textContent = t('could not continue: {reason}', { reason: r.reason });
|
|
414
419
|
} else {
|
|
415
420
|
ownedIds.add(r.session.sessionId);
|
|
416
421
|
// Two genuinely different outcomes, so say which one happened rather
|
|
417
422
|
// than printing a sentence that is true either way.
|
|
418
423
|
el.foot.textContent = r.session.forked
|
|
419
|
-
?
|
|
420
|
-
|
|
421
|
-
: `continued ${from.slice(0, 8)} itself — same session, same transcript.`;
|
|
424
|
+
? t('{id} is open in another process, so this is a copy — messages here do not reach it.', { id: from.slice(0, 8) })
|
|
425
|
+
: t('continued {id} itself — same session, same transcript.', { id: from.slice(0, 8) });
|
|
422
426
|
pollOwned();
|
|
423
427
|
}
|
|
424
428
|
} finally {
|
|
425
429
|
button.disabled = false;
|
|
426
|
-
button.textContent = 'Continue here';
|
|
430
|
+
button.textContent = t('Continue here');
|
|
427
431
|
}
|
|
428
432
|
}
|
|
429
433
|
|
|
@@ -459,13 +463,13 @@ el.newSession.addEventListener('click', () => {
|
|
|
459
463
|
|
|
460
464
|
/* The toolbar says what the canvas is doing. It is painted from the canvas's own
|
|
461
465
|
state, so a choice restored from storage shows without being re-made. */
|
|
462
|
-
const GROUP_WORD = { run: 'Workflow run', type: 'Agent type', parent: 'Parent', order: 'Order', none: 'None' };
|
|
463
|
-
const DENSITY_WORD = { auto: 'Auto', comfortable: 'Comfortable', compact: 'Compact' };
|
|
466
|
+
const GROUP_WORD = { run: t('Workflow run'), type: t('Agent type'), parent: t('Parent'), order: t('Order'), none: t('None') };
|
|
467
|
+
const DENSITY_WORD = { auto: t('Auto'), comfortable: t('Comfortable'), compact: t('Compact') };
|
|
464
468
|
const SHOW = {
|
|
465
|
-
all: { word: 'All', statuses: null },
|
|
466
|
-
attention: { word: 'Needs attention', statuses: ['failed', 'killed', 'stopped'], waiting: true },
|
|
467
|
-
running: { word: 'Running', statuses: ['running', 'starting'] },
|
|
468
|
-
failed: { word: 'Failed', statuses: ['failed', 'killed', 'stopped'] },
|
|
469
|
+
all: { word: t('All'), statuses: null },
|
|
470
|
+
attention: { word: t('Needs attention'), statuses: ['failed', 'killed', 'stopped'], waiting: true },
|
|
471
|
+
running: { word: t('Running'), statuses: ['running', 'starting'] },
|
|
472
|
+
failed: { word: t('Failed'), statuses: ['failed', 'killed', 'stopped'] },
|
|
469
473
|
};
|
|
470
474
|
let show = 'all';
|
|
471
475
|
const tbValue = {
|
|
@@ -479,15 +483,15 @@ function paintToolbar(st) {
|
|
|
479
483
|
// The grouping is one choice for both views: the Timeline's rows follow the graph's.
|
|
480
484
|
timeline.setGroup(st.group);
|
|
481
485
|
// When Auto has turned the cards into chips the button says so: the picture did not fit this pane as cards.
|
|
482
|
-
tbValue.density.textContent = st.density === 'auto' && st.drawnAs === 'compact' ? 'Auto \u00b7 compact' : (DENSITY_WORD[st.density] ?? st.density);
|
|
486
|
+
tbValue.density.textContent = st.density === 'auto' && st.drawnAs === 'compact' ? t('Auto \u00b7 compact') : (DENSITY_WORD[st.density] ?? st.density);
|
|
483
487
|
// Auto is a choice the canvas makes; say which one it made, and why.
|
|
484
488
|
el.tbDensity.title = st.density === 'auto' && st.drawnAs
|
|
485
|
-
?
|
|
486
|
-
+
|
|
489
|
+
? `${st.stacked ? t('Auto: {drawn}, agents of one type folded together.', { drawn: (DENSITY_WORD[st.drawnAs] ?? st.drawnAs).toLocaleLowerCase(numLocale()) }) : t('Auto: {drawn}.', { drawn: (DENSITY_WORD[st.drawnAs] ?? st.drawnAs).toLocaleLowerCase(numLocale()) })} `
|
|
490
|
+
+ t('Cards stay cards while the picture fits this pane at {pct}% or larger; choose Comfortable to keep them anyway.', { pct: Math.round(AUTO.readable * 100) })
|
|
487
491
|
: '';
|
|
488
492
|
const pct = `${Math.round(st.zoom * 100)}%`;
|
|
489
493
|
el.tbZoom.textContent = pct;
|
|
490
|
-
el.tbZoom.setAttribute('aria-label',
|
|
494
|
+
el.tbZoom.setAttribute('aria-label', t('Zoom {pct}. Back to 100%', { pct }));
|
|
491
495
|
// Enabled only while a card has been moved: with none there is nothing to reset.
|
|
492
496
|
if (el.tbReset) el.tbReset.disabled = !(st?.moved > 0);
|
|
493
497
|
paintFoldButtons();
|
|
@@ -497,8 +501,8 @@ function paintToolbar(st) {
|
|
|
497
501
|
Each is enabled only while it has something left to do there, so the pair always says what is true. */
|
|
498
502
|
const foldTarget = () => (view === 'timeline' ? timeline : view === 'list' ? list : null);
|
|
499
503
|
function foldState() {
|
|
500
|
-
const
|
|
501
|
-
if (
|
|
504
|
+
const ft = foldTarget();
|
|
505
|
+
if (ft) return ft.foldState();
|
|
502
506
|
const st = canvas.state();
|
|
503
507
|
return { groups: st.groups, canExpand: st.canExpand, canFold: st.canFold };
|
|
504
508
|
}
|
|
@@ -537,11 +541,11 @@ const canvas = new Canvas(document.getElementById('canvas'), {
|
|
|
537
541
|
const items = [];
|
|
538
542
|
if (n.kind === 'agent') {
|
|
539
543
|
items.push(st.focus
|
|
540
|
-
? { label: 'Show the whole graph', run: () => canvas.setFocus(null) }
|
|
541
|
-
: { label: 'Focus on this branch', run: () => canvas.setFocus(n.id) });
|
|
542
|
-
items.push({ label: 'Copy agent id', run: () => copyText(n.id, 'Copied the agent id') });
|
|
544
|
+
? { label: t('Show the whole graph'), run: () => canvas.setFocus(null) }
|
|
545
|
+
: { label: t('Focus on this branch'), run: () => canvas.setFocus(n.id) });
|
|
546
|
+
items.push({ label: t('Copy agent id'), run: () => copyText(n.id, t('Copied the agent id')) });
|
|
543
547
|
}
|
|
544
|
-
if (at.pinned) items.push({ label: 'Reset layout', run: () => resetLayout() });
|
|
548
|
+
if (at.pinned) items.push({ label: t('Reset layout'), run: () => resetLayout() });
|
|
545
549
|
if (!items.length) return;
|
|
546
550
|
openMenu({ getBoundingClientRect: () => ({ left: at.x, bottom: at.y }) }, items);
|
|
547
551
|
},
|
|
@@ -581,8 +585,8 @@ el.tbShow.addEventListener('click', (e) => {
|
|
|
581
585
|
const items = Object.entries(SHOW).map(([key, w]) => ({ label: w.word, checked: show === key, run: () => { setShow(key); setShowModel(showModel); } }));
|
|
582
586
|
const families = [...new Set((lastNodes ?? []).filter((n) => n.kind === 'agent').map((n) => modelFamily(n.model)).filter(Boolean))].sort();
|
|
583
587
|
if (families.length) {
|
|
584
|
-
items.push({ note: 'Model' }, { label: 'Every model', checked: showModel === null, run: () => setShowModel(null) },
|
|
585
|
-
...families.map((f) => ({ label:
|
|
588
|
+
items.push({ note: t('Model') }, { label: t('Every model'), checked: showModel === null, run: () => setShowModel(null) },
|
|
589
|
+
...families.map((f) => ({ label: t('Only {model}', { model: `${f[0].toUpperCase()}${f.slice(1)}` }), checked: showModel === f, run: () => setShowModel(f) })));
|
|
586
590
|
}
|
|
587
591
|
openMenu(el.tbShow, items);
|
|
588
592
|
});
|
|
@@ -623,15 +627,15 @@ el.tbOptions.addEventListener('click', (e) => {
|
|
|
623
627
|
const pick = (words, now, set) => Object.entries(words).map(([key, w]) => ({
|
|
624
628
|
label: typeof w === 'string' ? w : w.word, checked: now === key, run: () => set(key),
|
|
625
629
|
}));
|
|
626
|
-
const items = view === 'list' ? [] : [{ note: 'Group' }, ...pick(GROUP_WORD, st.group, (g) => canvas.setGroup(g))];
|
|
627
|
-
if (view === 'graph') items.push({ note: 'Density' }, ...pick(DENSITY_WORD, st.density, (d) => canvas.setDensity(d)));
|
|
628
|
-
items.push({ note: 'Show' }, ...pick(SHOW, show, (key) => setShow(key)));
|
|
630
|
+
const items = view === 'list' ? [] : [{ note: t('Group') }, ...pick(GROUP_WORD, st.group, (g) => canvas.setGroup(g))];
|
|
631
|
+
if (view === 'graph') items.push({ note: t('Density') }, ...pick(DENSITY_WORD, st.density, (d) => canvas.setDensity(d)));
|
|
632
|
+
items.push({ note: t('Show') }, ...pick(SHOW, show, (key) => setShow(key)));
|
|
629
633
|
if (view === 'graph' && st.groups > 0) {
|
|
630
|
-
items.push({ note: 'Groups' }, { label: 'Expand all', run: () => canvas.expandAll() }, { label: 'Fold all', run: () => canvas.foldAll() });
|
|
634
|
+
items.push({ note: t('Groups') }, { label: t('Expand all'), run: () => canvas.expandAll() }, { label: t('Fold all'), run: () => canvas.foldAll() });
|
|
631
635
|
}
|
|
632
636
|
if (view === 'graph') {
|
|
633
637
|
const open = canvas.spendOpen;
|
|
634
|
-
items.push({ note: 'Session summary' }, { label: open ? 'Hide time, tokens and cost' : 'Show time, tokens and cost', run: () => canvas.showSpend(!open) });
|
|
638
|
+
items.push({ note: t('Session summary') }, { label: open ? t('Hide time, tokens and cost') : t('Show time, tokens and cost'), run: () => canvas.showSpend(!open) });
|
|
635
639
|
}
|
|
636
640
|
openMenu(el.tbOptions, items);
|
|
637
641
|
});
|
|
@@ -649,14 +653,14 @@ function resetLayout() {
|
|
|
649
653
|
const target = zoomed();
|
|
650
654
|
const was = target.resetLayout();
|
|
651
655
|
if (!was) return;
|
|
652
|
-
toast('Layout reset', { label: 'Undo', run: () => { if (target.restoreLayout(was)) toast('Layout restored'); } });
|
|
656
|
+
toast(t('Layout reset'), { label: t('Undo'), run: () => { if (target.restoreLayout(was)) toast(t('Layout restored')); } });
|
|
653
657
|
if (target === mapView) paintZoom(mapView.zoom, mapView.moved);
|
|
654
658
|
}
|
|
655
659
|
/** The zoom and Reset as the map has them: the graph's go through paintToolbar. */
|
|
656
660
|
function paintZoom(k, moved) {
|
|
657
661
|
const pct = `${Math.round(k * 100)}%`;
|
|
658
662
|
el.tbZoom.textContent = pct;
|
|
659
|
-
el.tbZoom.setAttribute('aria-label',
|
|
663
|
+
el.tbZoom.setAttribute('aria-label', t('Zoom {pct}. Back to 100%', { pct }));
|
|
660
664
|
if (el.tbReset) el.tbReset.disabled = !(moved > 0);
|
|
661
665
|
}
|
|
662
666
|
el.tbReset.addEventListener('click', resetLayout);
|
|
@@ -675,7 +679,7 @@ let modelsData = null;
|
|
|
675
679
|
let modelsFor = null;
|
|
676
680
|
let modelsAt = 0;
|
|
677
681
|
const models = new Models(el.models, {
|
|
678
|
-
onCopy: (text) => navigator.clipboard.writeText(text).then(() => toast('Copied — send it in your own session to lower the hold'), () => toast(
|
|
682
|
+
onCopy: (text) => navigator.clipboard.writeText(text).then(() => toast(t('Copied — send it in your own session to lower the hold')), () => toast(t('Not copied — select it: {text}', { text }))),
|
|
679
683
|
});
|
|
680
684
|
let modelsBusy = false;
|
|
681
685
|
async function loadModels(force = false) {
|
|
@@ -727,8 +731,8 @@ let mapBusy = false;
|
|
|
727
731
|
let mapCompare = null;
|
|
728
732
|
const mapView = new MapView(el.map, {
|
|
729
733
|
onCopy: (text, what) => navigator.clipboard.writeText(text).then(
|
|
730
|
-
() => toast(what === 'path' ?
|
|
731
|
-
() => toast(
|
|
734
|
+
() => toast(what === 'path' ? t('Copied {text}', { text }) : t('Copied — paste it into a session as a task')),
|
|
735
|
+
() => toast(t('Not copied — select it: {text}', { text }))),
|
|
732
736
|
onCompare: (cwd) => { mapCompare = cwd; loadMap(true); },
|
|
733
737
|
onZoom: (k) => { if (view === 'map') paintZoom(k, mapView.moved); },
|
|
734
738
|
});
|
|
@@ -765,18 +769,21 @@ list.now = () => serverNow();
|
|
|
765
769
|
// Under 640px there is no room for the graph beside anything: the List is what a phone opens on.
|
|
766
770
|
const phoneBand = window.matchMedia('(max-width: 639px)');
|
|
767
771
|
|
|
772
|
+
/** A range key ("15m", "1h", "all") as the toolbar says it, in the panel's language. */
|
|
773
|
+
const rangeWord = (r) => (r === 'all' ? t('all')
|
|
774
|
+
: String(r).replace(/^(\d+)([mh])$/, (_, n, u) => (u === 'm' ? t('{n}m', { n }) : t('{n}h', { n }))));
|
|
768
775
|
function paintTimelineBar(st) {
|
|
769
|
-
el.tbRange.textContent = st.range;
|
|
770
|
-
el.tbRange.setAttribute('aria-label',
|
|
776
|
+
el.tbRange.textContent = rangeWord(st.range);
|
|
777
|
+
el.tbRange.setAttribute('aria-label', t('Time shown: {range}', { range: st.range === 'all' ? t('the whole session') : rangeWord(st.range) }));
|
|
771
778
|
el.tbRangeIn.disabled = !st.canZoomIn;
|
|
772
779
|
el.tbRangeOut.disabled = !st.canZoomOut;
|
|
773
780
|
// The range is in the buttons' own names too: on a narrow toolbar the label between them is not shown.
|
|
774
|
-
el.tbRangeIn.setAttribute('aria-label',
|
|
775
|
-
el.tbRangeOut.setAttribute('aria-label',
|
|
781
|
+
el.tbRangeIn.setAttribute('aria-label', t('Show less time (now {range})', { range: rangeWord(st.range) }));
|
|
782
|
+
el.tbRangeOut.setAttribute('aria-label', t('Show more time (now {range})', { range: rangeWord(st.range) }));
|
|
776
783
|
el.tbFollow.setAttribute('aria-checked', String(st.follow));
|
|
777
784
|
// There is no "now" to follow in a session that has ended.
|
|
778
785
|
el.tbFollow.disabled = !st.live;
|
|
779
|
-
el.tbFollow.title = st.live ? 'Keep the view at now' : 'This session has ended: there is no now to follow';
|
|
786
|
+
el.tbFollow.title = st.live ? t('Keep the view at now') : t('This session has ended: there is no now to follow');
|
|
780
787
|
}
|
|
781
788
|
|
|
782
789
|
/**
|
|
@@ -864,7 +871,7 @@ getJson('/api/palette')
|
|
|
864
871
|
// to be said out loud, or the panel is drawing "not measured" as a fact about the agents.
|
|
865
872
|
if (p && p.measured === false) {
|
|
866
873
|
document.body.dataset.paletteMeasured = 'false';
|
|
867
|
-
el.foot.textContent =
|
|
874
|
+
el.foot.textContent = t('Agent identity not measured — {reason}', { reason: p.reason });
|
|
868
875
|
}
|
|
869
876
|
})
|
|
870
877
|
.catch(() => { /* every agent is drawn with the common mark, which is what is known */ });
|
|
@@ -915,14 +922,14 @@ function metaRows(n) {
|
|
|
915
922
|
const rows = [];
|
|
916
923
|
const row = (k, v) => { if (v != null && v !== '') rows.push([k, String(v)]); };
|
|
917
924
|
if (n.kind === 'session') {
|
|
918
|
-
row('session', n.sessionId); row('cwd', n.cwd); row('branch', n.gitBranch);
|
|
919
|
-
row('model', n.model); row('turns', n.turns);
|
|
920
|
-
row('context', n.tokens?.toLocaleString()); row('cli', n.version);
|
|
925
|
+
row(t('session'), n.sessionId); row('cwd', n.cwd); row(t('branch'), n.gitBranch);
|
|
926
|
+
row(t('model'), n.model); row(t('turns'), n.turns);
|
|
927
|
+
row(t('context'), n.tokens?.toLocaleString(numLocale())); row('cli', n.version);
|
|
921
928
|
} else {
|
|
922
|
-
row('type', n.agentType ?? 'unknown'); row('status', n.status);
|
|
923
|
-
row('depth', n.spawnDepth); row('model', n.model);
|
|
924
|
-
row('turns', n.turns); row('last tool', n.lastTool);
|
|
925
|
-
row('agent id', n.id);
|
|
929
|
+
row(t('type'), n.agentType ?? t('unknown')); row(t('status'), n.status);
|
|
930
|
+
row(t('depth'), n.spawnDepth); row(t('model'), n.model);
|
|
931
|
+
row(t('turns'), n.turns); row(t('last tool'), n.lastTool);
|
|
932
|
+
row(t('agent id'), n.id);
|
|
926
933
|
}
|
|
927
934
|
const dl = document.createElement('dl');
|
|
928
935
|
for (const [k, v] of rows) {
|
|
@@ -942,12 +949,12 @@ function isection(title, ...children) {
|
|
|
942
949
|
|
|
943
950
|
/** The agent transcript, as far as it has been read: null when it is there to use. */
|
|
944
951
|
function detailGap(n, detail) {
|
|
945
|
-
if (!detail || detail.loading) return node('div', 'ihint', 'Reading the agent transcript…');
|
|
952
|
+
if (!detail || detail.loading) return node('div', 'ihint', t('Reading the agent transcript…'));
|
|
946
953
|
if (detail.measured !== false) return null;
|
|
947
954
|
const hint = node('div', 'ihint');
|
|
948
|
-
hint.append(node('strong', null, 'Not measured'), node('div', null, detail.reason || 'no reason given'));
|
|
955
|
+
hint.append(node('strong', null, t('Not measured')), node('div', null, detail.reason || t('no reason given')));
|
|
949
956
|
if (detail.transient) {
|
|
950
|
-
const retry = node('button', 'btn sm', 'Retry');
|
|
957
|
+
const retry = node('button', 'btn sm', t('Retry'));
|
|
951
958
|
retry.type = 'button';
|
|
952
959
|
retry.addEventListener('click', () => {
|
|
953
960
|
detailCache.delete(`${n.id}:${n.status ?? '?'}`);
|
|
@@ -974,18 +981,18 @@ function paintInspector() {
|
|
|
974
981
|
const head = node('div', 'ihead');
|
|
975
982
|
const close = node('button', 'btn icon sm ghost');
|
|
976
983
|
close.type = 'button';
|
|
977
|
-
close.setAttribute('aria-label', 'Close');
|
|
978
|
-
close.title = 'Close (Esc)';
|
|
984
|
+
close.setAttribute('aria-label', t('Close'));
|
|
985
|
+
close.title = t('Close (Esc)');
|
|
979
986
|
close.append(icon(ICON.close));
|
|
980
987
|
close.addEventListener('click', () => { canvas.clearSelection(); showInspector(null); });
|
|
981
988
|
// On a phone the inspector is a page of its own, and the way out of a page is back.
|
|
982
989
|
const back = node('button', 'btn icon sm ghost ihead-back');
|
|
983
990
|
back.type = 'button';
|
|
984
|
-
back.setAttribute('aria-label', 'Back to the list');
|
|
991
|
+
back.setAttribute('aria-label', t('Back to the list'));
|
|
985
992
|
back.append(icon(ICON.back));
|
|
986
993
|
back.addEventListener('click', () => { canvas.clearSelection(); showInspector(null); });
|
|
987
994
|
close.classList.add('ihead-close');
|
|
988
|
-
const name = isSession ? 'Session' : n.kind === 'workflow' ? (n.workflowId ?? 'Workflow run') : shownType(n);
|
|
995
|
+
const name = isSession ? t('Session') : n.kind === 'workflow' ? (n.workflowId ?? t('Workflow run')) : shownType(n);
|
|
989
996
|
const iname = node('h3', 'iname', name);
|
|
990
997
|
if (n.kind === 'agent') iname.title = n.agentType ?? '';
|
|
991
998
|
head.append(back, canvas.tileFor(n), iname, close);
|
|
@@ -993,14 +1000,14 @@ function paintInspector() {
|
|
|
993
1000
|
const meta = node('div', 'imeta');
|
|
994
1001
|
const pill = node('span', 'pill');
|
|
995
1002
|
// A state nobody read is said, not left as a blank pill.
|
|
996
|
-
pill.append(dot(st.tone), node('span', null, st.word ?? 'State not measured'));
|
|
1003
|
+
pill.append(dot(st.tone), node('span', null, st.word ?? t('State not measured')));
|
|
997
1004
|
meta.append(pill, node('span', 'sub', timeLine(n, serverNow())));
|
|
998
1005
|
// The role is the name; the type as it is declared is said here, where there is room for it.
|
|
999
1006
|
if (n.kind === 'agent' && n.agentType) meta.append(node('code', 'ireal', n.agentType));
|
|
1000
1007
|
|
|
1001
1008
|
const task = node('div', 'itask', isSession
|
|
1002
1009
|
? (sessionLabel(current) ?? n.sessionId ?? '')
|
|
1003
|
-
: n.kind === 'workflow' ?
|
|
1010
|
+
: n.kind === 'workflow' ? (n.members === 1 ? t('{n} agent', { n: 1 }) : t('{n} agents', { n: n.members ?? 0 })) : (n.description ?? ''));
|
|
1004
1011
|
|
|
1005
1012
|
const tabs = node('div', 'itabs');
|
|
1006
1013
|
tabs.setAttribute('role', 'tablist');
|
|
@@ -1022,18 +1029,18 @@ function paintInspector() {
|
|
|
1022
1029
|
else paintAgentOverview(body, n, detail);
|
|
1023
1030
|
|
|
1024
1031
|
const foot = node('div', 'ifoot');
|
|
1025
|
-
const open = node('button', 'btn', 'Open conversation');
|
|
1032
|
+
const open = node('button', 'btn', t('Open conversation'));
|
|
1026
1033
|
open.type = 'button';
|
|
1027
1034
|
open.addEventListener('click', () => { if (current) openConversation(current); });
|
|
1028
1035
|
const file = transcriptPathOf(n, detail);
|
|
1029
1036
|
const copy = node('button', 'btn icon');
|
|
1030
1037
|
copy.type = 'button';
|
|
1031
1038
|
copy.append(icon(ICON.copy));
|
|
1032
|
-
copy.setAttribute('aria-label', 'Copy the transcript path');
|
|
1039
|
+
copy.setAttribute('aria-label', t('Copy the transcript path'));
|
|
1033
1040
|
// A path nobody handed over is not built from parts here: the button says it has none.
|
|
1034
1041
|
copy.disabled = !file;
|
|
1035
|
-
copy.title = file ?
|
|
1036
|
-
copy.addEventListener('click', () => copyText(file, 'Copied'));
|
|
1042
|
+
copy.title = file ? `${t('Copy the transcript path')}\n${file}` : t('Transcript path not read yet');
|
|
1043
|
+
copy.addEventListener('click', () => copyText(file, t('Copied')));
|
|
1037
1044
|
foot.append(open, copy);
|
|
1038
1045
|
|
|
1039
1046
|
el.inspector.replaceChildren(inspectorSplit, head, meta, task, tabs, body, foot);
|
|
@@ -1041,12 +1048,12 @@ function paintInspector() {
|
|
|
1041
1048
|
|
|
1042
1049
|
function paintAgentOverview(body, n, detail) {
|
|
1043
1050
|
const grid = node('div', 'itiles');
|
|
1044
|
-
for (const
|
|
1051
|
+
for (const tl of tiles(n, serverNow())) {
|
|
1045
1052
|
const tile = node('div', 'itile');
|
|
1046
|
-
const value = node('strong', null,
|
|
1047
|
-
if (!
|
|
1048
|
-
if (
|
|
1049
|
-
tile.append(node('span', 'itile-k',
|
|
1053
|
+
const value = node('strong', null, tl.value);
|
|
1054
|
+
if (!tl.measured) { tile.classList.add('unmeasured'); tile.title = t('Not measured — {reason}', { reason: tl.why }); }
|
|
1055
|
+
if (tl.bad) value.classList.add('bad');
|
|
1056
|
+
tile.append(node('span', 'itile-k', tl.label), value);
|
|
1050
1057
|
grid.append(tile);
|
|
1051
1058
|
}
|
|
1052
1059
|
body.append(grid);
|
|
@@ -1062,14 +1069,14 @@ function paintAgentOverview(body, n, detail) {
|
|
|
1062
1069
|
const dl = node('dl', 'icard');
|
|
1063
1070
|
for (const l of lines) dl.append(node('dt', null, l.label), node('dd', null, l.text));
|
|
1064
1071
|
// Why the gate called it critical, in its own words (`files ... is on a critical path: ...` or `change: ...`).
|
|
1065
|
-
if (work.riskWhy) dl.append(node('dt', null, 'why critical'), node('dd', null, work.riskWhy));
|
|
1066
|
-
const sec = isection('Task card', dl);
|
|
1072
|
+
if (work.riskWhy) dl.append(node('dt', null, t('why critical')), node('dd', null, work.riskWhy));
|
|
1073
|
+
const sec = isection(t('Task card'), dl);
|
|
1067
1074
|
if (work.risk) { const tag = node('span', 'mv-tag', work.risk); tag.dataset.risk = work.risk; sec.children[0].append(tag); }
|
|
1068
1075
|
body.append(sec);
|
|
1069
1076
|
}
|
|
1070
1077
|
// Not a crew agent: no card and no line, and that is said, so the empty space is not read as a record lost.
|
|
1071
1078
|
const outside = !row && !lines.length ? outsideRouting(n.agentType) : null;
|
|
1072
|
-
if (outside) body.append(isection('Model routing', node('p', 'istep-note', outside)));
|
|
1079
|
+
if (outside) body.append(isection(t('Model routing'), node('p', 'istep-note', outside)));
|
|
1073
1080
|
if (row || lines.length) {
|
|
1074
1081
|
const ol = node('ol', 'isteps');
|
|
1075
1082
|
work.steps.forEach((st, i) => {
|
|
@@ -1080,7 +1087,7 @@ function paintAgentOverview(body, n, detail) {
|
|
|
1080
1087
|
li.append(node('span', 'istep-n', String(i + 1)), says);
|
|
1081
1088
|
ol.append(li);
|
|
1082
1089
|
});
|
|
1083
|
-
const sec = isection('Model and verify', ...(work.steps.length ? [ol] : []), ...(work.note ? [node('p', 'istep-note', work.note)] : []));
|
|
1090
|
+
const sec = isection(t('Model and verify'), ...(work.steps.length ? [ol] : []), ...(work.note ? [node('p', 'istep-note', work.note)] : []));
|
|
1084
1091
|
body.append(sec);
|
|
1085
1092
|
}
|
|
1086
1093
|
}
|
|
@@ -1093,14 +1100,14 @@ function paintAgentOverview(body, n, detail) {
|
|
|
1093
1100
|
const dd = node('dd', null, m.text);
|
|
1094
1101
|
if (m.note) dd.title = m.note;
|
|
1095
1102
|
if (m.goto) {
|
|
1096
|
-
const go = node('button', 'ilink', 'show');
|
|
1103
|
+
const go = node('button', 'ilink', t('show'));
|
|
1097
1104
|
go.type = 'button';
|
|
1098
1105
|
go.addEventListener('click', () => goTo(m.goto));
|
|
1099
1106
|
dd.append(' ', go);
|
|
1100
1107
|
}
|
|
1101
1108
|
dl.append(node('dt', null, m.label), dd);
|
|
1102
1109
|
}
|
|
1103
|
-
body.append(isection('Model', dl));
|
|
1110
|
+
body.append(isection(t('Model'), dl));
|
|
1104
1111
|
}
|
|
1105
1112
|
|
|
1106
1113
|
const gap = n.kind === 'agent' ? detailGap(n, detail) : null;
|
|
@@ -1119,30 +1126,30 @@ function paintAgentOverview(body, n, detail) {
|
|
|
1119
1126
|
for (const s of skills) {
|
|
1120
1127
|
const chip = node('button', 'ichip', s);
|
|
1121
1128
|
chip.type = 'button';
|
|
1122
|
-
chip.title = 'Copy the skill name';
|
|
1123
|
-
chip.addEventListener('click', () => copyText(s,
|
|
1129
|
+
chip.title = t('Copy the skill name');
|
|
1130
|
+
chip.addEventListener('click', () => copyText(s, t('Copied {text}', { text: s })));
|
|
1124
1131
|
chips.append(chip);
|
|
1125
1132
|
}
|
|
1126
|
-
body.append(isection('Skills applied',
|
|
1127
|
-
gap ?? (skills.length ? chips : node('div', 'ihint', 'None — this agent invoked no skill.'))));
|
|
1133
|
+
body.append(isection(t('Skills applied'),
|
|
1134
|
+
gap ?? (skills.length ? chips : node('div', 'ihint', t('None — this agent invoked no skill.')))));
|
|
1128
1135
|
|
|
1129
1136
|
const by = delegatedBy(n, [...canvas.nodes.values()]);
|
|
1130
1137
|
if (by) {
|
|
1131
1138
|
const link = node('button', 'ilink', by.text);
|
|
1132
1139
|
link.type = 'button';
|
|
1133
1140
|
link.addEventListener('click', () => canvas.focus(by.id));
|
|
1134
|
-
body.append(isection('Delegated by', link));
|
|
1141
|
+
body.append(isection(t('Delegated by'), link));
|
|
1135
1142
|
}
|
|
1136
1143
|
|
|
1137
|
-
if (gap) { body.append(isection('Report', node('div', 'ihint', 'Reading the agent transcript…'))); return; }
|
|
1144
|
+
if (gap) { body.append(isection(t('Report'), node('div', 'ihint', t('Reading the agent transcript…')))); return; }
|
|
1138
1145
|
const rep = reportOf(n, read);
|
|
1139
|
-
if (!rep.present) { body.append(isection('Report', node('div', 'ihint', rep.text))); return; }
|
|
1146
|
+
if (!rep.present) { body.append(isection(t('Report'), node('div', 'ihint', rep.text))); return; }
|
|
1140
1147
|
const md = node('div', 'md');
|
|
1141
1148
|
md.innerHTML = renderMarkdown(rep.text);
|
|
1142
|
-
const copy = node('button', 'btn sm ghost', 'Copy');
|
|
1149
|
+
const copy = node('button', 'btn sm ghost', t('Copy'));
|
|
1143
1150
|
copy.type = 'button';
|
|
1144
|
-
copy.addEventListener('click', () => copyText(rep.text, 'Copied the report'));
|
|
1145
|
-
const sec = isection('Report', md);
|
|
1151
|
+
copy.addEventListener('click', () => copyText(rep.text, t('Copied the report')));
|
|
1152
|
+
const sec = isection(t('Report'), md);
|
|
1146
1153
|
sec.querySelector('.isec-h').append(copy);
|
|
1147
1154
|
body.append(sec);
|
|
1148
1155
|
}
|
|
@@ -1152,13 +1159,13 @@ function paintSessionOverview(body, n) {
|
|
|
1152
1159
|
// Said in the place the missing controls would have been: there is no approval dock for this session, and
|
|
1153
1160
|
// no way to write to it, and this is why.
|
|
1154
1161
|
const banner = node('div', 'ibanner');
|
|
1155
|
-
banner.append(node('p', null, 'This session was not started here, so Studio can read it but not write to it.'));
|
|
1162
|
+
banner.append(node('p', null, t('This session was not started here, so Studio can read it but not write to it.')));
|
|
1156
1163
|
const acts = node('div', 'ibanner-acts');
|
|
1157
|
-
const cont = node('button', 'btn sm primary', 'Continue here');
|
|
1164
|
+
const cont = node('button', 'btn sm primary', t('Continue here'));
|
|
1158
1165
|
cont.type = 'button';
|
|
1159
|
-
cont.title = 'Start a new session holding a copy of this conversation. It does not write into the original.';
|
|
1166
|
+
cont.title = t('Start a new session holding a copy of this conversation. It does not write into the original.');
|
|
1160
1167
|
cont.addEventListener('click', () => continueHere(cont));
|
|
1161
|
-
const term = node('button', 'btn sm', 'Open in terminal');
|
|
1168
|
+
const term = node('button', 'btn sm', t('Open in terminal'));
|
|
1162
1169
|
term.type = 'button';
|
|
1163
1170
|
term.addEventListener('click', (e) => { e.stopPropagation(); offerTerminal(term, current); });
|
|
1164
1171
|
acts.append(cont, term);
|
|
@@ -1178,29 +1185,29 @@ function paintSessionOverview(body, n) {
|
|
|
1178
1185
|
if (c.age) row.append(node('span', 'sub', c.age));
|
|
1179
1186
|
return row;
|
|
1180
1187
|
});
|
|
1181
|
-
const box = isection('In the background', ...rows);
|
|
1188
|
+
const box = isection(t('In the background'), ...rows);
|
|
1182
1189
|
body.append(box);
|
|
1183
1190
|
}
|
|
1184
1191
|
|
|
1185
1192
|
const b = kitData?.board;
|
|
1186
1193
|
let board;
|
|
1187
|
-
if (!kitData) board = node('div', 'ihint', 'Reading Crewforth…');
|
|
1194
|
+
if (!kitData) board = node('div', 'ihint', t('Reading Crewforth…'));
|
|
1188
1195
|
else if (!b?.measured) board = unmeasured(b?.reason);
|
|
1189
1196
|
else if (!b.present) board = node('div', 'ihint', b.text);
|
|
1190
1197
|
else board = node('pre', 'md-code', b.text);
|
|
1191
|
-
body.append(isection('Board', board));
|
|
1198
|
+
body.append(isection(t('Board'), board));
|
|
1192
1199
|
}
|
|
1193
1200
|
|
|
1194
1201
|
/** The agent's own transcript, read-only: what it was asked, what it did, what it said. */
|
|
1195
1202
|
function paintTranscript(body, n, detail) {
|
|
1196
|
-
const full = node('button', 'btn sm', 'Open full conversation');
|
|
1203
|
+
const full = node('button', 'btn sm', t('Open full conversation'));
|
|
1197
1204
|
full.type = 'button';
|
|
1198
1205
|
full.addEventListener('click', () => { if (current) openConversation(current); });
|
|
1199
1206
|
|
|
1200
1207
|
if (n.kind !== 'agent') {
|
|
1201
1208
|
body.append(node('div', 'ihint', n.kind === 'session'
|
|
1202
|
-
? 'The session\'s conversation is in the conversation panel.'
|
|
1203
|
-
: 'A workflow run has no transcript of its own; its agents do.'), full);
|
|
1209
|
+
? t('The session\'s conversation is in the conversation panel.')
|
|
1210
|
+
: t('A workflow run has no transcript of its own; its agents do.')), full);
|
|
1204
1211
|
return;
|
|
1205
1212
|
}
|
|
1206
1213
|
const gap = detailGap(n, detail);
|
|
@@ -1208,21 +1215,21 @@ function paintTranscript(body, n, detail) {
|
|
|
1208
1215
|
|
|
1209
1216
|
const prompt = node('div', 'md');
|
|
1210
1217
|
prompt.innerHTML = renderMarkdown(detail.prompt ?? '');
|
|
1211
|
-
body.append(isection('Asked', detail.prompt ? prompt : node('div', 'ihint', 'No prompt recorded.')));
|
|
1218
|
+
body.append(isection(t('Asked'), detail.prompt ? prompt : node('div', 'ihint', t('No prompt recorded.'))));
|
|
1212
1219
|
|
|
1213
1220
|
const list = node('div', 'itimeline');
|
|
1214
|
-
if (!detail.timeline?.length) list.append(node('div', 'ihint', 'No tool calls recorded.'));
|
|
1221
|
+
if (!detail.timeline?.length) list.append(node('div', 'ihint', t('No tool calls recorded.')));
|
|
1215
1222
|
for (const [i, step] of (detail.timeline ?? []).entries()) {
|
|
1216
1223
|
const r = node('div', 'istep');
|
|
1217
1224
|
r.append(node('span', 'istep-n', String(i + 1)), node('span', 'istep-tool', step.name), node('span', 'istep-label', step.label ?? ''));
|
|
1218
1225
|
list.append(r);
|
|
1219
1226
|
}
|
|
1220
|
-
body.append(isection('Did', list));
|
|
1227
|
+
body.append(isection(t('Did'), list));
|
|
1221
1228
|
|
|
1222
1229
|
const said = node('div', 'isaid');
|
|
1223
|
-
for (const
|
|
1230
|
+
for (const text of detail.narration ?? []) {
|
|
1224
1231
|
const md = node('div', 'md md-narration');
|
|
1225
|
-
md.innerHTML = renderMarkdown(
|
|
1232
|
+
md.innerHTML = renderMarkdown(text);
|
|
1226
1233
|
said.append(md);
|
|
1227
1234
|
}
|
|
1228
1235
|
const rep = reportOf(n, detail);
|
|
@@ -1233,7 +1240,7 @@ function paintTranscript(body, n, detail) {
|
|
|
1233
1240
|
} else {
|
|
1234
1241
|
said.append(node('div', 'ihint', rep.text));
|
|
1235
1242
|
}
|
|
1236
|
-
body.append(isection('Said', said), full);
|
|
1243
|
+
body.append(isection(t('Said'), said), full);
|
|
1237
1244
|
}
|
|
1238
1245
|
|
|
1239
1246
|
/* ------------------------------------------------------------------ kit
|
|
@@ -1258,24 +1265,24 @@ async function loadKit(sessionId) {
|
|
|
1258
1265
|
|
|
1259
1266
|
function unmeasured(reason) {
|
|
1260
1267
|
const d = node('div', 'ihint');
|
|
1261
|
-
d.append(node('strong', null, 'Not measured'));
|
|
1262
|
-
d.append(node('div', null, reason || 'no reason given'));
|
|
1263
|
-
d.append(node('div', 'why', 'Which is not the same as nothing having happened.'));
|
|
1268
|
+
d.append(node('strong', null, t('Not measured')));
|
|
1269
|
+
d.append(node('div', null, reason || t('no reason given')));
|
|
1270
|
+
d.append(node('div', 'why', t('Which is not the same as nothing having happened.')));
|
|
1264
1271
|
return d;
|
|
1265
1272
|
}
|
|
1266
1273
|
|
|
1267
1274
|
function paintStats(body) {
|
|
1268
|
-
if (!kitData) { body.append(node('div', 'ihint', 'Reading Crewforth…')); return; }
|
|
1275
|
+
if (!kitData) { body.append(node('div', 'ihint', t('Reading Crewforth…'))); return; }
|
|
1269
1276
|
const s = kitData.stats;
|
|
1270
1277
|
if (!s?.measured) {
|
|
1271
1278
|
const reason = s?.reason ?? kitData.reason ?? '';
|
|
1272
1279
|
const box = node('div', 'ihint');
|
|
1273
|
-
box.append(node('strong', null, 'Not measured'), node('div', null, reason || 'no reason given'),
|
|
1274
|
-
node('div', 'why', 'This is not a zero: there is nothing to read here.'));
|
|
1280
|
+
box.append(node('strong', null, t('Not measured')), node('div', null, reason || t('no reason given')),
|
|
1281
|
+
node('div', 'why', t('This is not a zero: there is nothing to read here.')));
|
|
1275
1282
|
// The script that measures this comes with a full install. That is said only when its absence is the
|
|
1276
1283
|
// reason, and no command is typed here: the page offers commands the server gave it, and it gave none.
|
|
1277
1284
|
if (/session-stats\.sh is not present|not installed/.test(reason)) {
|
|
1278
|
-
box.append(node('div', 'why', 'A full install of Crewforth in this project adds it.'));
|
|
1285
|
+
box.append(node('div', 'why', t('A full install of Crewforth in this project adds it.')));
|
|
1279
1286
|
}
|
|
1280
1287
|
body.append(box);
|
|
1281
1288
|
return;
|
|
@@ -1283,12 +1290,12 @@ function paintStats(body) {
|
|
|
1283
1290
|
const dl = document.createElement('dl');
|
|
1284
1291
|
for (const [k, v] of Object.entries(s.metrics)) {
|
|
1285
1292
|
const dt = node('dt', null, k.replace(/_/g, ' '));
|
|
1286
|
-
const dd = node('dd', null, v.toLocaleString());
|
|
1293
|
+
const dd = node('dd', null, v.toLocaleString(numLocale()));
|
|
1287
1294
|
if (v > 0 && /runaway|errors|interrupts/.test(k)) dd.classList.add('bad');
|
|
1288
1295
|
dl.append(dt, dd);
|
|
1289
1296
|
}
|
|
1290
1297
|
body.append(dl);
|
|
1291
|
-
body.append(node('div', 'ihint', 'session-stats.sh --raw, over this transcript.'));
|
|
1298
|
+
body.append(node('div', 'ihint', t('session-stats.sh --raw, over this transcript.')));
|
|
1292
1299
|
}
|
|
1293
1300
|
|
|
1294
1301
|
async function revokeAllowance(tool, button) {
|
|
@@ -1304,13 +1311,13 @@ async function revokeAllowance(tool, button) {
|
|
|
1304
1311
|
res = { ok: false, reason: e.message };
|
|
1305
1312
|
}
|
|
1306
1313
|
if (!res.ok) {
|
|
1307
|
-
toast(
|
|
1314
|
+
toast(t('Not revoked — {reason}', { reason: res.reason ?? t('no reason given') }));
|
|
1308
1315
|
button.disabled = false;
|
|
1309
1316
|
return;
|
|
1310
1317
|
}
|
|
1311
1318
|
const sn = owned.get(sessionId);
|
|
1312
1319
|
if (sn) owned.set(sessionId, { ...sn, alwaysAllowed: res.always ?? [] });
|
|
1313
|
-
toast(res.revoked ?
|
|
1320
|
+
toast(res.revoked ? t('{tool} asks again from the next call', { tool }) : t('{tool} was already asking', { tool }));
|
|
1314
1321
|
if (inspectorNode) paintInspector();
|
|
1315
1322
|
}
|
|
1316
1323
|
|
|
@@ -1323,24 +1330,24 @@ function paintGates(body) {
|
|
|
1323
1330
|
const list = node('div', 'iallow');
|
|
1324
1331
|
for (const tool of allowed) {
|
|
1325
1332
|
const row = node('div', 'iallow-row');
|
|
1326
|
-
const revoke = node('button', 'btn sm', 'Revoke');
|
|
1333
|
+
const revoke = node('button', 'btn sm', t('Revoke'));
|
|
1327
1334
|
revoke.type = 'button';
|
|
1328
|
-
revoke.setAttribute('aria-label',
|
|
1335
|
+
revoke.setAttribute('aria-label', t('Revoke {name}', { name: tool }));
|
|
1329
1336
|
revoke.addEventListener('click', () => revokeAllowance(tool, revoke));
|
|
1330
|
-
row.append(node('span', 'iallow-tool', tool), node('span', 'sub', 'not asked about here'), node('span', 'row-fill'), revoke);
|
|
1337
|
+
row.append(node('span', 'iallow-tool', tool), node('span', 'sub', t('not asked about here')), node('span', 'row-fill'), revoke);
|
|
1331
1338
|
list.append(row);
|
|
1332
1339
|
}
|
|
1333
1340
|
// What a session allowance is and is not: the dock stops asking, and nothing more. A call Claude Code
|
|
1334
1341
|
// would itself have asked a person about is still refused, because nobody saw it.
|
|
1335
|
-
if (allowed.length) list.append(node('div', 'ihint', 'Studio does not ask about these again. Claude Code\'s own checks still apply to each call.'));
|
|
1336
|
-
body.append(isection('Allowed for this session',
|
|
1337
|
-
allowed.length ? list : node('div', 'ihint', 'Nothing — every tool call asks first.')));
|
|
1342
|
+
if (allowed.length) list.append(node('div', 'ihint', t('Studio does not ask about these again. Claude Code\'s own checks still apply to each call.')));
|
|
1343
|
+
body.append(isection(t('Allowed for this session'),
|
|
1344
|
+
allowed.length ? list : node('div', 'ihint', t('Nothing — every tool call asks first.'))));
|
|
1338
1345
|
} else if (mine) {
|
|
1339
|
-
body.append(isection('Allowed for this session',
|
|
1340
|
-
node('div', 'ihint', 'This session runs without the approval gate, so nothing asks and nothing was allowed here.')));
|
|
1346
|
+
body.append(isection(t('Allowed for this session'),
|
|
1347
|
+
node('div', 'ihint', t('This session runs without the approval gate, so nothing asks and nothing was allowed here.'))));
|
|
1341
1348
|
} else {
|
|
1342
|
-
body.append(isection('Allowed for this session',
|
|
1343
|
-
node('div', 'ihint', 'This session was not started here, so its tool calls do not wait for an answer in Studio.')));
|
|
1349
|
+
body.append(isection(t('Allowed for this session'),
|
|
1350
|
+
node('div', 'ihint', t('This session was not started here, so its tool calls do not wait for an answer in Studio.'))));
|
|
1344
1351
|
}
|
|
1345
1352
|
|
|
1346
1353
|
// Happened: what was seen with a time on it. The answers given to requests come from this page; the hook
|
|
@@ -1351,7 +1358,7 @@ function paintGates(body) {
|
|
|
1351
1358
|
const rows = [];
|
|
1352
1359
|
for (const a of answers.slice(-12)) {
|
|
1353
1360
|
const row = node('div', 'gate-row');
|
|
1354
|
-
row.append(node('span', 'gate-when', new Date(a.at - clock.offset).toLocaleTimeString()));
|
|
1361
|
+
row.append(node('span', 'gate-when', new Date(a.at - clock.offset).toLocaleTimeString(numLocale())));
|
|
1355
1362
|
row.append(node('span', 'gate-name', `${a.toolName}${a.agentType ? ` · ${roleName(a.agentType)}` : ''}`));
|
|
1356
1363
|
const v = node('span', 'gate-verdict', OUTCOME_WORD[a.outcome]);
|
|
1357
1364
|
v.dataset.verdict = a.outcome.startsWith('allowed') ? 'ALLOW' : a.outcome === 'answered-elsewhere' ? 'ASK' : 'BLOCK';
|
|
@@ -1360,7 +1367,7 @@ function paintGates(body) {
|
|
|
1360
1367
|
}
|
|
1361
1368
|
for (const e of live.slice(-12)) {
|
|
1362
1369
|
const row = node('div', 'gate-row');
|
|
1363
|
-
row.append(node('span', 'gate-when', new Date(e.at - clock.offset).toLocaleTimeString()));
|
|
1370
|
+
row.append(node('span', 'gate-when', new Date(e.at - clock.offset).toLocaleTimeString(numLocale())));
|
|
1364
1371
|
row.append(node('span', 'gate-name', e.name ?? e.event ?? 'hook'));
|
|
1365
1372
|
const v = node('span', 'gate-verdict', e.phase === 'started' ? 'ran' : (e.exitCode === 2 ? 'blocked' : e.outcome ?? 'done'));
|
|
1366
1373
|
v.dataset.verdict = e.exitCode === 2 ? 'BLOCK' : 'ALLOW';
|
|
@@ -1369,18 +1376,18 @@ function paintGates(body) {
|
|
|
1369
1376
|
}
|
|
1370
1377
|
// One list, newest first: both kinds carry the server's time.
|
|
1371
1378
|
rows.sort((x, y) => y.at - x.at);
|
|
1372
|
-
body.append(isection('Happened', ...rows.map((r) => r.row)));
|
|
1379
|
+
body.append(isection(t('Happened'), ...rows.map((r) => r.row)));
|
|
1373
1380
|
}
|
|
1374
1381
|
|
|
1375
|
-
if (!kitData) { body.append(node('div', 'ihint', 'Reading Crewforth…')); return; }
|
|
1382
|
+
if (!kitData) { body.append(node('div', 'ihint', t('Reading Crewforth…'))); return; }
|
|
1376
1383
|
const log = kitData.log;
|
|
1377
1384
|
const rep = kitData.report;
|
|
1378
1385
|
|
|
1379
1386
|
const observed = [];
|
|
1380
1387
|
if (rep?.measured) {
|
|
1381
1388
|
const head = node('div', 'kit-sum');
|
|
1382
|
-
head.append(node('span', 'cv-bit',
|
|
1383
|
-
head.append(node('span', 'cv-bit',
|
|
1389
|
+
head.append(node('span', 'cv-bit', t('{n} rules', { n: rep.rules })));
|
|
1390
|
+
head.append(node('span', 'cv-bit', t('{n} decisions', { n: (rep.decisions ?? 0).toLocaleString(numLocale()) })));
|
|
1384
1391
|
observed.push(head);
|
|
1385
1392
|
} else {
|
|
1386
1393
|
observed.push(unmeasured(rep?.reason ?? kitData.reason));
|
|
@@ -1388,20 +1395,20 @@ function paintGates(body) {
|
|
|
1388
1395
|
|
|
1389
1396
|
if (!log?.measured) {
|
|
1390
1397
|
observed.push(unmeasured(log?.reason ?? kitData.reason));
|
|
1391
|
-
body.append(isection('Observed', ...observed));
|
|
1398
|
+
body.append(isection(t('Observed'), ...observed));
|
|
1392
1399
|
return;
|
|
1393
1400
|
}
|
|
1394
1401
|
|
|
1395
1402
|
// The distinction is the point: this file has no timestamp column, so these
|
|
1396
1403
|
// are decisions found in the log, not decisions seen happening.
|
|
1397
1404
|
observed.push(node('div', 'ihint',
|
|
1398
|
-
`${
|
|
1399
|
-
+ 'no timestamps in this format, so these are what the log holds, not when they ran'
|
|
1400
|
-
+ (log.commandsRecorded ? '' :
|
|
1405
|
+
`${t('{n} in the tail of gate-log.tsv', { n: log.total.toLocaleString(numLocale()) })}${log.truncated ? ` ${t('(truncated)')}` : ''} · `
|
|
1406
|
+
+ t('no timestamps in this format, so these are what the log holds, not when they ran')
|
|
1407
|
+
+ (log.commandsRecorded ? '' : ` · ${t('commands not recorded (CREW_GATE_LOG_CMD=1 records them)')}`)));
|
|
1401
1408
|
|
|
1402
1409
|
const counts = node('div', 'kit-sum');
|
|
1403
1410
|
for (const [k, v] of Object.entries(log.counts ?? {})) {
|
|
1404
|
-
const b = node('span', 'cv-bit', `${v.toLocaleString()} ${k}`);
|
|
1411
|
+
const b = node('span', 'cv-bit', `${v.toLocaleString(numLocale())} ${k}`);
|
|
1405
1412
|
b.dataset.verdict = k;
|
|
1406
1413
|
counts.append(b);
|
|
1407
1414
|
}
|
|
@@ -1416,7 +1423,7 @@ function paintGates(body) {
|
|
|
1416
1423
|
if (e.section) row.append(node('span', 'gate-sec', e.section));
|
|
1417
1424
|
observed.push(row);
|
|
1418
1425
|
}
|
|
1419
|
-
body.append(isection('Observed', ...observed));
|
|
1426
|
+
body.append(isection(t('Observed'), ...observed));
|
|
1420
1427
|
}
|
|
1421
1428
|
|
|
1422
1429
|
/* ------------------------------------------------------------ navigator
|
|
@@ -1512,7 +1519,7 @@ async function copyText(text, said) {
|
|
|
1512
1519
|
toast(said);
|
|
1513
1520
|
} catch {
|
|
1514
1521
|
// A page without clipboard access still has to hand the text over.
|
|
1515
|
-
toast(
|
|
1522
|
+
toast(t('Could not copy — {text}', { text }));
|
|
1516
1523
|
}
|
|
1517
1524
|
}
|
|
1518
1525
|
|
|
@@ -1598,15 +1605,15 @@ async function offerTerminal(anchor, sessionId) {
|
|
|
1598
1605
|
try {
|
|
1599
1606
|
plan = (await getJson(`/api/session/${encodeURIComponent(sessionId)}/terminal`)).plan;
|
|
1600
1607
|
} catch (e) {
|
|
1601
|
-
toast(
|
|
1608
|
+
toast(t('Could not read the terminal command — {reason}', { reason: e.message }));
|
|
1602
1609
|
return;
|
|
1603
1610
|
}
|
|
1604
|
-
if (!plan) { toast('Not measured — this session recorded no working directory to open a terminal in.'); return; }
|
|
1611
|
+
if (!plan) { toast(t('Not measured — this session recorded no working directory to open a terminal in.')); return; }
|
|
1605
1612
|
openMenu(anchor, [
|
|
1606
|
-
{ note:
|
|
1613
|
+
{ note: t('This will run in {where}:', { where: plan.via ?? t('a terminal') }) },
|
|
1607
1614
|
{ note: plan.line },
|
|
1608
1615
|
{
|
|
1609
|
-
label: 'Open terminal',
|
|
1616
|
+
label: t('Open terminal'),
|
|
1610
1617
|
primary: true,
|
|
1611
1618
|
run: async () => {
|
|
1612
1619
|
try {
|
|
@@ -1614,13 +1621,13 @@ async function offerTerminal(anchor, sessionId) {
|
|
|
1614
1621
|
method: 'POST', headers: { ...writeHeaders, 'content-type': 'application/json' }, body: '{}',
|
|
1615
1622
|
});
|
|
1616
1623
|
const out = await r.json();
|
|
1617
|
-
toast(out.ok ?
|
|
1624
|
+
toast(out.ok ? t('Opened in {where}', { where: plan.via ?? t('a terminal') }) : t('Could not open a terminal — {reason}', { reason: out.reason ?? t('no reason given') }));
|
|
1618
1625
|
} catch (e) {
|
|
1619
|
-
toast(
|
|
1626
|
+
toast(t('Could not open a terminal — {reason}', { reason: e.message }));
|
|
1620
1627
|
}
|
|
1621
1628
|
},
|
|
1622
1629
|
},
|
|
1623
|
-
{ label: 'Cancel', run: () => {} },
|
|
1630
|
+
{ label: t('Cancel'), run: () => {} },
|
|
1624
1631
|
]);
|
|
1625
1632
|
}
|
|
1626
1633
|
|
|
@@ -1652,8 +1659,8 @@ function renderSessions(data) {
|
|
|
1652
1659
|
if (!data.measured) {
|
|
1653
1660
|
renderNote(el.sessions, {
|
|
1654
1661
|
kind: 'unmeasured',
|
|
1655
|
-
title: 'No transcripts here',
|
|
1656
|
-
body: 'Nothing was read.',
|
|
1662
|
+
title: t('No transcripts here'),
|
|
1663
|
+
body: t('Nothing was read.'),
|
|
1657
1664
|
why: data.reason,
|
|
1658
1665
|
});
|
|
1659
1666
|
return;
|
|
@@ -1680,8 +1687,8 @@ function paintFirstRun() {
|
|
|
1680
1687
|
// with no transcripts this panel knows of no project to start it in.
|
|
1681
1688
|
if (first && !el.firstRun.firstChild) {
|
|
1682
1689
|
el.firstRun.append(
|
|
1683
|
-
node('strong', null, 'No Claude Code sessions yet'),
|
|
1684
|
-
node('p', null, 'Studio reads the sessions Claude Code saves on this machine. Start one in a terminal in any project and it shows up here within a few seconds.'),
|
|
1690
|
+
node('strong', null, t('No Claude Code sessions yet')),
|
|
1691
|
+
node('p', null, t('Studio reads the sessions Claude Code saves on this machine. Start one in a terminal in any project and it shows up here within a few seconds.')),
|
|
1685
1692
|
);
|
|
1686
1693
|
}
|
|
1687
1694
|
el.firstRun.hidden = !first;
|
|
@@ -1704,11 +1711,11 @@ function mapRow(p, latest) {
|
|
|
1704
1711
|
row.tabIndex = 0;
|
|
1705
1712
|
row.setAttribute('role', 'treeitem');
|
|
1706
1713
|
row.dataset.map = p.key;
|
|
1707
|
-
const words = node('span', 'map-word', 'Map');
|
|
1714
|
+
const words = node('span', 'map-word', t('Map'));
|
|
1708
1715
|
row.append(icon(ICON.map), words, node('span', 'row-fill'));
|
|
1709
|
-
if (p.map?.findings) { const n = node('span', 'map-count', String(p.map.findings)); n.title =
|
|
1710
|
-
else if (!p.map) row.append(node('span', 'sub', 'no map'));
|
|
1711
|
-
row.title = p.map ? 'Open the project map' : 'This project has no .crewforth/crew-map.json yet';
|
|
1716
|
+
if (p.map?.findings) { const n = node('span', 'map-count', String(p.map.findings)); n.title = t('{n} finding(s) on the map', { n: p.map.findings }); row.append(n); }
|
|
1717
|
+
else if (!p.map) row.append(node('span', 'sub', t('no map')));
|
|
1718
|
+
row.title = p.map ? t('Open the project map') : t('This project has no .crewforth/crew-map.json yet');
|
|
1712
1719
|
const go = () => { selectSession(latest.sessionId); setView('map'); };
|
|
1713
1720
|
row.addEventListener('click', go);
|
|
1714
1721
|
row.addEventListener('keydown', (e) => { if (e.target === row && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); go(); } });
|
|
@@ -1728,13 +1735,13 @@ function sessionRow(project, sn) {
|
|
|
1728
1735
|
const top = node('span', 'srow-top');
|
|
1729
1736
|
const d = dot(status.tone);
|
|
1730
1737
|
// The word travels with the colour; where there is no status to give, say why.
|
|
1731
|
-
d.title = status.key === 'unmeasured' ? 'Status not measured — the session list could not be read' : (status.word ?? '');
|
|
1738
|
+
d.title = status.key === 'unmeasured' ? t('Status not measured — the session list could not be read') : (status.word ?? '');
|
|
1732
1739
|
top.append(d);
|
|
1733
1740
|
|
|
1734
1741
|
if (renaming === sn.sessionId) {
|
|
1735
1742
|
const input = node('input', 'srow-rename');
|
|
1736
1743
|
input.value = name;
|
|
1737
|
-
input.setAttribute('aria-label',
|
|
1744
|
+
input.setAttribute('aria-label', t('Rename {name}', { name }));
|
|
1738
1745
|
const done = (save) => {
|
|
1739
1746
|
if (renaming !== sn.sessionId) return;
|
|
1740
1747
|
renaming = null;
|
|
@@ -1759,18 +1766,18 @@ function sessionRow(project, sn) {
|
|
|
1759
1766
|
|
|
1760
1767
|
const more = node('button', 'row-more');
|
|
1761
1768
|
more.type = 'button';
|
|
1762
|
-
more.setAttribute('aria-label',
|
|
1769
|
+
more.setAttribute('aria-label', t('More for {name}', { name }));
|
|
1763
1770
|
more.setAttribute('aria-haspopup', 'menu');
|
|
1764
1771
|
more.append(icon(ICON.more));
|
|
1765
1772
|
more.addEventListener('click', (e) => {
|
|
1766
1773
|
e.stopPropagation();
|
|
1767
1774
|
openMenu(more, [
|
|
1768
|
-
{ label: 'Rename', run: () => { renaming = sn.sessionId; paintProjects(); } },
|
|
1769
|
-
{ label: 'Copy session id', run: () => copyText(sn.sessionId, 'Copied the session id') },
|
|
1770
|
-
{ label: 'Open in terminal', run: () => offerTerminal(more, sn.sessionId) },
|
|
1775
|
+
{ label: t('Rename'), run: () => { renaming = sn.sessionId; paintProjects(); } },
|
|
1776
|
+
{ label: t('Copy session id'), run: () => copyText(sn.sessionId, t('Copied the session id')) },
|
|
1777
|
+
{ label: t('Open in terminal'), run: () => offerTerminal(more, sn.sessionId) },
|
|
1771
1778
|
hiddenIds.has(sn.sessionId)
|
|
1772
|
-
? { label: 'Show in list', run: () => { setHidden(sn.sessionId, false); paintProjects(); } }
|
|
1773
|
-
: { label: 'Hide from list', run: () => { setHidden(sn.sessionId, true); paintProjects(); } },
|
|
1779
|
+
? { label: t('Show in list'), run: () => { setHidden(sn.sessionId, false); paintProjects(); } }
|
|
1780
|
+
: { label: t('Hide from list'), run: () => { setHidden(sn.sessionId, true); paintProjects(); } },
|
|
1774
1781
|
]);
|
|
1775
1782
|
});
|
|
1776
1783
|
top.append(more);
|
|
@@ -1883,7 +1890,7 @@ function paintProjects() {
|
|
|
1883
1890
|
if (p.local !== false && p.exists && sessions.length) group.append(mapRow(p, sessions[0]));
|
|
1884
1891
|
for (const sn of sessions) group.append(sessionRow(p, sn));
|
|
1885
1892
|
if (p.total > p.sessions.length) {
|
|
1886
|
-
group.append(node('div', 'nav-line',
|
|
1893
|
+
group.append(node('div', 'nav-line', t('{n} older session(s) not listed', { n: p.total - p.sessions.length })));
|
|
1887
1894
|
}
|
|
1888
1895
|
}
|
|
1889
1896
|
frag.append(group);
|
|
@@ -1891,13 +1898,13 @@ function paintProjects() {
|
|
|
1891
1898
|
|
|
1892
1899
|
if (!shown) {
|
|
1893
1900
|
renderNote(el.sessions, filterText
|
|
1894
|
-
? { title: 'No match', body:
|
|
1895
|
-
: { title: 'No projects', body: 'Measured — no transcript was found on this machine.' });
|
|
1901
|
+
? { title: t('No match'), body: t('Nothing here is called “{query}”.', { query: filterText }) }
|
|
1902
|
+
: { title: t('No projects'), body: t('Measured — no transcript was found on this machine.') });
|
|
1896
1903
|
return;
|
|
1897
1904
|
}
|
|
1898
1905
|
|
|
1899
1906
|
for (const o of (data.origins ?? []).filter((x) => x.ok === false)) {
|
|
1900
|
-
frag.append(node('div', 'nav-line bad',
|
|
1907
|
+
frag.append(node('div', 'nav-line bad', t('{name} unreachable — {reason}', { name: o.name, reason: o.reason })));
|
|
1901
1908
|
}
|
|
1902
1909
|
const lineWith = (text, label, run) => {
|
|
1903
1910
|
const line = node('div', 'nav-line');
|
|
@@ -1910,18 +1917,18 @@ function paintProjects() {
|
|
|
1910
1917
|
};
|
|
1911
1918
|
if (hiddenIds.size && !filterText) {
|
|
1912
1919
|
frag.append(lineWith(
|
|
1913
|
-
showHidden ?
|
|
1914
|
-
showHidden ? 'hide' : 'show',
|
|
1920
|
+
showHidden ? t('{n} hidden session(s) shown', { n: hiddenIds.size }) : t('{n} session(s) hidden from this list', { n: hiddenCount }),
|
|
1921
|
+
showHidden ? t('hide') : t('show'),
|
|
1915
1922
|
() => { showHidden = !showHidden; paintProjects(); },
|
|
1916
1923
|
));
|
|
1917
1924
|
}
|
|
1918
1925
|
if (missing > 0 && !filterText) {
|
|
1919
1926
|
frag.append(lineWith(
|
|
1920
|
-
|
|
1927
|
+
t('{n} project(s) hidden — their directories no longer exist', { n: missing }), t('show'),
|
|
1921
1928
|
() => { showMissing = true; paintProjects(); },
|
|
1922
1929
|
));
|
|
1923
1930
|
} else if (showMissing && !filterText) {
|
|
1924
|
-
frag.append(lineWith('Showing projects whose directories no longer exist', 'hide',
|
|
1931
|
+
frag.append(lineWith(t('Showing projects whose directories no longer exist'), t('hide'),
|
|
1925
1932
|
() => { showMissing = false; paintProjects(); }));
|
|
1926
1933
|
}
|
|
1927
1934
|
el.sessions.replaceChildren(frag);
|
|
@@ -1953,12 +1960,12 @@ function paintLive() {
|
|
|
1953
1960
|
if (!data.measured) {
|
|
1954
1961
|
el.liveCount.hidden = false;
|
|
1955
1962
|
el.liveCount.textContent = '?';
|
|
1956
|
-
el.liveCount.title = 'Not measured';
|
|
1963
|
+
el.liveCount.title = t('Not measured');
|
|
1957
1964
|
renderNote(el.fleet, {
|
|
1958
1965
|
kind: 'unmeasured',
|
|
1959
|
-
title: 'Not measured',
|
|
1960
|
-
body: 'This is not the same as "nothing is running" — nothing was read.',
|
|
1961
|
-
why: data.reason || 'no reason reported',
|
|
1966
|
+
title: t('Not measured'),
|
|
1967
|
+
body: t('This is not the same as "nothing is running" — nothing was read.'),
|
|
1968
|
+
why: data.reason || t('no reason reported'),
|
|
1962
1969
|
});
|
|
1963
1970
|
return;
|
|
1964
1971
|
}
|
|
@@ -1966,7 +1973,7 @@ function paintLive() {
|
|
|
1966
1973
|
const live = liveSessions(data);
|
|
1967
1974
|
el.liveCount.hidden = live.length === 0;
|
|
1968
1975
|
el.liveCount.textContent = String(live.length);
|
|
1969
|
-
el.liveCount.title =
|
|
1976
|
+
el.liveCount.title = t('{n} working or waiting', { n: live.length });
|
|
1970
1977
|
|
|
1971
1978
|
const rows = live.filter((s) => !filterText
|
|
1972
1979
|
|| [s.name, s.cwd, s.sessionId, s.origin].some((v) => typeof v === 'string' && v.toLowerCase().includes(filterText)));
|
|
@@ -1974,15 +1981,15 @@ function paintLive() {
|
|
|
1974
1981
|
if (!live.length) {
|
|
1975
1982
|
const open = (data.sessions ?? []).length;
|
|
1976
1983
|
renderNote(el.fleet, {
|
|
1977
|
-
title: 'No sessions running',
|
|
1984
|
+
title: t('No sessions running'),
|
|
1978
1985
|
body: open
|
|
1979
|
-
?
|
|
1980
|
-
: 'Measured — the machine has none open.',
|
|
1986
|
+
? t('Measured — {n} open on this machine, none of them working or waiting.', { n: open })
|
|
1987
|
+
: t('Measured — the machine has none open.'),
|
|
1981
1988
|
});
|
|
1982
1989
|
return;
|
|
1983
1990
|
}
|
|
1984
1991
|
if (!rows.length) {
|
|
1985
|
-
renderNote(el.fleet, { title: 'No match', body:
|
|
1992
|
+
renderNote(el.fleet, { title: t('No match'), body: t('No live session is called “{query}”.', { query: filterText }) });
|
|
1986
1993
|
return;
|
|
1987
1994
|
}
|
|
1988
1995
|
|
|
@@ -1997,21 +2004,21 @@ function paintLive() {
|
|
|
1997
2004
|
top.append(node('span', 'row-fill'), node('span', 'sub', ago(s.startedAt, Date.now())));
|
|
1998
2005
|
row.append(top);
|
|
1999
2006
|
const where = known?.project.label ?? shortPath(s.cwd);
|
|
2000
|
-
const said = status.key === 'waiting' && s.waitingFor ?
|
|
2007
|
+
const said = status.key === 'waiting' && s.waitingFor ? `${t('Needs you')} · ${s.waitingFor}` : (status.word ?? s.status);
|
|
2001
2008
|
row.append(marked([where, said].filter(Boolean).join(' · '), 'sub srow-sub'));
|
|
2002
2009
|
|
|
2003
2010
|
if (s.local !== false && s.sessionId) {
|
|
2004
2011
|
row.tabIndex = 0;
|
|
2005
2012
|
row.dataset.id = s.sessionId;
|
|
2006
2013
|
row.setAttribute('aria-current', String(s.sessionId === current));
|
|
2007
|
-
row.title =
|
|
2014
|
+
row.title = t('Open {name}', { name });
|
|
2008
2015
|
const open = () => selectSession(s.sessionId);
|
|
2009
2016
|
row.addEventListener('click', open);
|
|
2010
2017
|
row.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } });
|
|
2011
2018
|
} else {
|
|
2012
2019
|
// A session on another machine has no transcript here to open.
|
|
2013
2020
|
row.classList.add('remote');
|
|
2014
|
-
row.title =
|
|
2021
|
+
row.title = t('{name} — on {origin}. Its transcript is on that machine.', { name, origin: s.origin });
|
|
2015
2022
|
}
|
|
2016
2023
|
return row;
|
|
2017
2024
|
}));
|
|
@@ -2023,16 +2030,16 @@ function machineRow(m) {
|
|
|
2023
2030
|
top.append(icon(ICON.machine), node('span', 'mname', m.name), node('span', 'row-fill'));
|
|
2024
2031
|
if (m.kind === 'snapshot') {
|
|
2025
2032
|
// Recorded by a session, not asked just now: the label says which.
|
|
2026
|
-
top.append(node('span', 'badge', 'snapshot'));
|
|
2033
|
+
top.append(node('span', 'badge', t('snapshot')));
|
|
2027
2034
|
row.append(top);
|
|
2028
2035
|
const seen = seenAgo(m.seenAt, Date.now());
|
|
2029
2036
|
row.append(node('div', 'sub srow-sub', [m.status, m.note, seen].filter(Boolean).join(' · ')));
|
|
2030
|
-
row.title =
|
|
2037
|
+
row.title = t('{name} — on another machine. Its transcript lives there, so the panel can list it but not draw it.', { name: m.name });
|
|
2031
2038
|
} else {
|
|
2032
|
-
top.append(node('span', 'badge', 'peer'));
|
|
2039
|
+
top.append(node('span', 'badge', t('peer')));
|
|
2033
2040
|
row.append(top);
|
|
2034
2041
|
const sub = node('div', `sub srow-sub${m.ok ? '' : ' bad'}`,
|
|
2035
|
-
m.ok ?
|
|
2042
|
+
m.ok ? t('{n} session(s) · asked just now', { n: m.sessions ?? 0 }) : t('unreachable — {reason}', { reason: m.reason ?? t('no reason given') }));
|
|
2036
2043
|
row.append(sub);
|
|
2037
2044
|
}
|
|
2038
2045
|
return row;
|
|
@@ -2046,7 +2053,7 @@ function paintMachines() {
|
|
|
2046
2053
|
if (self) {
|
|
2047
2054
|
const row = node('div', 'srow remote');
|
|
2048
2055
|
const top = node('span', 'srow-top');
|
|
2049
|
-
top.append(icon(ICON.machine), node('span', 'mname', self.name), node('span', 'row-fill'), node('span', 'badge', 'this machine'));
|
|
2056
|
+
top.append(icon(ICON.machine), node('span', 'mname', self.name), node('span', 'row-fill'), node('span', 'badge', t('this machine')));
|
|
2050
2057
|
row.append(top);
|
|
2051
2058
|
frag.append(row);
|
|
2052
2059
|
}
|
|
@@ -2058,10 +2065,10 @@ function paintMachines() {
|
|
|
2058
2065
|
const note = node('div', 'nav-note');
|
|
2059
2066
|
renderNote(note, {
|
|
2060
2067
|
kind: m.rosterMeasured ? '' : 'unmeasured',
|
|
2061
|
-
title: m.rosterMeasured ? 'None reachable' : 'Not measured',
|
|
2068
|
+
title: m.rosterMeasured ? t('None reachable') : t('Not measured'),
|
|
2062
2069
|
body: m.rosterMeasured
|
|
2063
|
-
? 'A connected session looked and found no machines besides this one.'
|
|
2064
|
-
: 'No session here has recorded a list of other machines yet.',
|
|
2070
|
+
? t('A connected session looked and found no machines besides this one.')
|
|
2071
|
+
: t('No session here has recorded a list of other machines yet.'),
|
|
2065
2072
|
why: m.rosterMeasured ? null : m.rosterReason,
|
|
2066
2073
|
});
|
|
2067
2074
|
frag.append(note);
|
|
@@ -2104,7 +2111,7 @@ async function remoteWrite(method, path, body) {
|
|
|
2104
2111
|
}
|
|
2105
2112
|
|
|
2106
2113
|
/** A time today or tomorrow as the clock on the wall says it, e.g. 23:40. */
|
|
2107
|
-
const clockTime = (ms) => (Number.isFinite(ms) ? new Date(ms).toLocaleTimeString(
|
|
2114
|
+
const clockTime = (ms) => (Number.isFinite(ms) ? new Date(ms).toLocaleTimeString(numLocale(), { hour: '2-digit', minute: '2-digit' }) : '—');
|
|
2108
2115
|
|
|
2109
2116
|
let showTranscripts = false; // the owner's choice for the next viewer: transcripts stay hidden unless ticked
|
|
2110
2117
|
|
|
@@ -2112,7 +2119,7 @@ async function addViewer(button) {
|
|
|
2112
2119
|
button.disabled = true;
|
|
2113
2120
|
const res = await remoteWrite('POST', '/api/remote', { transcripts: showTranscripts });
|
|
2114
2121
|
if (!res.ok) {
|
|
2115
|
-
toast(
|
|
2122
|
+
toast(t('No viewer added — {reason}', { reason: res.reason ?? t('no reason given') }));
|
|
2116
2123
|
button.disabled = false;
|
|
2117
2124
|
return;
|
|
2118
2125
|
}
|
|
@@ -2129,45 +2136,45 @@ async function revokeViewer(v, button) {
|
|
|
2129
2136
|
button.disabled = true;
|
|
2130
2137
|
const res = await remoteWrite('DELETE', `/api/remote/${encodeURIComponent(v.id)}`);
|
|
2131
2138
|
if (!res.ok) {
|
|
2132
|
-
toast(
|
|
2139
|
+
toast(t('Not revoked — {reason}', { reason: res.reason ?? t('no reason given') }));
|
|
2133
2140
|
button.disabled = false;
|
|
2134
2141
|
return;
|
|
2135
2142
|
}
|
|
2136
2143
|
remote = { ...(remote ?? {}), viewers: res.viewers ?? [] };
|
|
2137
2144
|
if (handed?.id === v.id) handed = null;
|
|
2138
|
-
toast(res.revoked ?
|
|
2145
|
+
toast(res.revoked ? t('{name} can no longer read this panel', { name: v.label }) : t('{name} was already revoked', { name: v.label }));
|
|
2139
2146
|
paintMachines();
|
|
2140
2147
|
}
|
|
2141
2148
|
|
|
2142
2149
|
function viewerRow(v) {
|
|
2143
2150
|
const row = node('div', 'srow remote');
|
|
2144
2151
|
const top = node('span', 'srow-top');
|
|
2145
|
-
const revoke = node('button', 'btn sm ghost', 'Revoke');
|
|
2152
|
+
const revoke = node('button', 'btn sm ghost', t('Revoke'));
|
|
2146
2153
|
revoke.type = 'button';
|
|
2147
|
-
revoke.setAttribute('aria-label',
|
|
2154
|
+
revoke.setAttribute('aria-label', t('Revoke {name}', { name: v.label }));
|
|
2148
2155
|
revoke.addEventListener('click', () => revokeViewer(v, revoke));
|
|
2149
2156
|
top.append(icon(ICON.machine), node('span', 'mname', v.label), node('span', 'row-fill'), revoke);
|
|
2150
2157
|
row.append(top);
|
|
2151
2158
|
const seen = v.lastSeenAt
|
|
2152
|
-
? `${seenAgo(v.lastSeenAt, Date.now())} · ${v.requests} request
|
|
2153
|
-
: 'link not opened yet';
|
|
2159
|
+
? `${seenAgo(v.lastSeenAt, Date.now())} · ${v.requests === 1 ? t('{n} request', { n: 1 }) : t('{n} requests', { n: v.requests })}${v.open ? ` · ${t('watching now')}` : ''}`
|
|
2160
|
+
: t('link not opened yet');
|
|
2154
2161
|
row.append(node('div', 'sub srow-sub', seen));
|
|
2155
|
-
row.append(node('div', 'sub srow-sub',
|
|
2162
|
+
row.append(node('div', 'sub srow-sub', `${t('until {time}', { time: clockTime(v.expiresAt) })} · ${v.transcripts ? t('transcripts shown') : t('transcripts hidden')}${v.addresses?.length ? ` · ${t('from {where}', { where: v.addresses.join(', ') })}` : ''}`));
|
|
2156
2163
|
return row;
|
|
2157
2164
|
}
|
|
2158
2165
|
|
|
2159
2166
|
function remoteSection() {
|
|
2160
2167
|
const box = node('div', 'nav-foot rv');
|
|
2161
2168
|
const head = node('div', 'rv-head');
|
|
2162
|
-
const add = node('button', 'btn sm', 'Add viewer');
|
|
2169
|
+
const add = node('button', 'btn sm', t('Add viewer'));
|
|
2163
2170
|
add.type = 'button';
|
|
2164
2171
|
const list = remote?.viewers ?? [];
|
|
2165
2172
|
if (remote?.max != null && list.length >= remote.max) {
|
|
2166
2173
|
add.disabled = true;
|
|
2167
|
-
add.title =
|
|
2174
|
+
add.title = t('{n} viewers already; revoke one first', { n: remote.max });
|
|
2168
2175
|
}
|
|
2169
2176
|
add.addEventListener('click', () => addViewer(add));
|
|
2170
|
-
head.append(node('span', 'rv-title', 'Remote viewers'), node('span', 'row-fill'), add);
|
|
2177
|
+
head.append(node('span', 'rv-title', t('Remote viewers')), node('span', 'row-fill'), add);
|
|
2171
2178
|
box.append(head);
|
|
2172
2179
|
|
|
2173
2180
|
// Transcripts stay hidden on a new link unless this is ticked before Add viewer.
|
|
@@ -2175,24 +2182,26 @@ function remoteSection() {
|
|
|
2175
2182
|
const tick = document.createElement('input');
|
|
2176
2183
|
tick.type = 'checkbox'; tick.checked = showTranscripts;
|
|
2177
2184
|
tick.addEventListener('change', () => { showTranscripts = tick.checked; });
|
|
2178
|
-
opt.append(tick, document.createTextNode('
|
|
2185
|
+
opt.append(tick, document.createTextNode(` ${t('Show transcripts on the next link')}`));
|
|
2179
2186
|
box.append(opt);
|
|
2180
2187
|
|
|
2181
2188
|
if (handed) {
|
|
2182
2189
|
const give = node('div', 'rv-give');
|
|
2183
2190
|
give.append(node('div', 'sub rv-say', handed.wifi
|
|
2184
|
-
?
|
|
2185
|
-
:
|
|
2191
|
+
? t('Link for {name}, shown once. Scan it with a phone on the same Wi-Fi. It lasts until {time}; {transcripts}.', { name: handed.label, time: clockTime(handed.expiresAt), transcripts: handed.transcripts ? t('transcripts shown') : t('transcripts hidden') })
|
|
2192
|
+
: handed.lanErrors.length
|
|
2193
|
+
? t('Link for {name}, shown once. No Wi-Fi address was found ({errors}), so it opens on this machine only.', { name: handed.label, errors: handed.lanErrors.join('; ') })
|
|
2194
|
+
: t('Link for {name}, shown once. No Wi-Fi address was found, so it opens on this machine only.', { name: handed.label })));
|
|
2186
2195
|
if (handed.wifi) {
|
|
2187
2196
|
const code = node('div', 'rv-qr');
|
|
2188
2197
|
code.innerHTML = qrSvg(handed.wifi, { px: 3 });
|
|
2189
2198
|
give.append(code);
|
|
2190
2199
|
}
|
|
2191
2200
|
const url = node('code', 'rv-url', handed.url);
|
|
2192
|
-
const copy = node('button', 'btn sm', 'Copy link');
|
|
2201
|
+
const copy = node('button', 'btn sm', t('Copy link'));
|
|
2193
2202
|
copy.type = 'button';
|
|
2194
|
-
copy.addEventListener('click', () => copyText(handed.url, 'Link copied'));
|
|
2195
|
-
const done = node('button', 'btn sm ghost', 'Done');
|
|
2203
|
+
copy.addEventListener('click', () => copyText(handed.url, t('Link copied')));
|
|
2204
|
+
const done = node('button', 'btn sm ghost', t('Done'));
|
|
2196
2205
|
done.type = 'button';
|
|
2197
2206
|
done.addEventListener('click', () => { handed = null; paintMachines(); });
|
|
2198
2207
|
const acts = node('div', 'rv-acts');
|
|
@@ -2204,7 +2213,7 @@ function remoteSection() {
|
|
|
2204
2213
|
for (const v of list) box.append(viewerRow(v));
|
|
2205
2214
|
if (!list.length) {
|
|
2206
2215
|
box.append(node('div', 'sub rv-say',
|
|
2207
|
-
'A viewer watches this panel from a phone on the same Wi-Fi and changes nothing. Its link lasts eight hours. With no viewer, the server listens on 127.0.0.1 only.'));
|
|
2216
|
+
t('A viewer watches this panel from a phone on the same Wi-Fi and changes nothing. Its link lasts eight hours. With no viewer, the server listens on 127.0.0.1 only.')));
|
|
2208
2217
|
}
|
|
2209
2218
|
return box;
|
|
2210
2219
|
}
|
|
@@ -2215,11 +2224,11 @@ function paintOtherMachines() {
|
|
|
2215
2224
|
const others = [...m.peers, ...m.remote];
|
|
2216
2225
|
el.otherMachines.hidden = navTab !== 'projects' || others.length === 0;
|
|
2217
2226
|
if (el.otherMachines.hidden) return;
|
|
2218
|
-
const label = node('button', 'nav-foot-label', 'Other machines');
|
|
2227
|
+
const label = node('button', 'nav-foot-label', t('Other machines'));
|
|
2219
2228
|
label.type = 'button';
|
|
2220
2229
|
label.addEventListener('click', () => setTab('machines'));
|
|
2221
2230
|
const rows = others.slice(0, 2).map(machineRow);
|
|
2222
|
-
const more = others.length > 2 ? [node('div', 'nav-line',
|
|
2231
|
+
const more = others.length > 2 ? [node('div', 'nav-line', t('{n} more in Machines', { n: others.length - 2 }))] : [];
|
|
2223
2232
|
el.otherMachines.replaceChildren(label, ...rows, ...more);
|
|
2224
2233
|
}
|
|
2225
2234
|
|
|
@@ -2236,13 +2245,13 @@ function paintCrumb() {
|
|
|
2236
2245
|
if (!found) { el.crumb.replaceChildren(); return; }
|
|
2237
2246
|
const proj = node('button', 'crumb-project', found.project.label);
|
|
2238
2247
|
proj.type = 'button';
|
|
2239
|
-
proj.title =
|
|
2248
|
+
proj.title = t('Show {name} in the navigator', { name: found.project.label });
|
|
2240
2249
|
proj.addEventListener('click', () => showProject(found.project.key));
|
|
2241
2250
|
const sep = icon(ICON.chevron);
|
|
2242
2251
|
sep.classList.add('crumb-sep');
|
|
2243
2252
|
const name = node('span', 'crumb-session', sessionName(found.session, labels.all()));
|
|
2244
2253
|
// The branch is on the session's row and on its card; here it is one hover away.
|
|
2245
|
-
name.title = found.session.branch ?
|
|
2254
|
+
name.title = found.session.branch ? t('on {branch}', { branch: found.session.branch }) : '';
|
|
2246
2255
|
el.crumb.replaceChildren(proj, sep, name);
|
|
2247
2256
|
fitBar();
|
|
2248
2257
|
}
|
|
@@ -2268,7 +2277,7 @@ function paintSummary(stats, extra = []) {
|
|
|
2268
2277
|
const b = node('button', 'pill chip');
|
|
2269
2278
|
b.type = 'button';
|
|
2270
2279
|
b.setAttribute('aria-pressed', String(statusFilter === c.status));
|
|
2271
|
-
b.title = statusFilter === c.status ? 'Show every agent' :
|
|
2280
|
+
b.title = statusFilter === c.status ? t('Show every agent') : t('Show only {what}', { what: c.text.replace(/^\d+ /, '') });
|
|
2272
2281
|
b.append(dot(c.tone), document.createTextNode(c.text));
|
|
2273
2282
|
b.addEventListener('click', () => toggle(c));
|
|
2274
2283
|
return b;
|
|
@@ -2282,7 +2291,7 @@ function paintSummary(stats, extra = []) {
|
|
|
2282
2291
|
const words = [...chips.map((c) => c.text), ...extra].join(', ');
|
|
2283
2292
|
const b = node('button', 'pill chip chip-compact');
|
|
2284
2293
|
b.type = 'button';
|
|
2285
|
-
b.setAttribute('aria-label',
|
|
2294
|
+
b.setAttribute('aria-label', t('Session summary: {words}. Open details', { words }));
|
|
2286
2295
|
b.setAttribute('aria-haspopup', 'menu');
|
|
2287
2296
|
b.setAttribute('aria-pressed', String(Boolean(statusFilter)));
|
|
2288
2297
|
b.title = words;
|
|
@@ -2312,10 +2321,10 @@ window.addEventListener('resize', fitBar);
|
|
|
2312
2321
|
function setStatusFilter(status, repaint = true) {
|
|
2313
2322
|
statusFilter = status;
|
|
2314
2323
|
show = 'all';
|
|
2315
|
-
tbValue.show.textContent = status ? 'One status' : SHOW.all.word;
|
|
2324
|
+
tbValue.show.textContent = status ? t('One status') : SHOW.all.word;
|
|
2316
2325
|
canvas.setFilter(status ? [status] : null);
|
|
2317
2326
|
timeline.setFilter(status ? [status] : null);
|
|
2318
|
-
if (repaint) paintSummary(lastStats, lastStats?.malformed ? [
|
|
2327
|
+
if (repaint) paintSummary(lastStats, lastStats?.malformed ? [t('{n} malformed', { n: lastStats.malformed })] : []);
|
|
2319
2328
|
}
|
|
2320
2329
|
|
|
2321
2330
|
/** The Show menu: the same filter the chips are, by what the viewer is looking for rather than by one status. */
|
|
@@ -2325,7 +2334,7 @@ function setShow(key) {
|
|
|
2325
2334
|
tbValue.show.textContent = SHOW[show].word;
|
|
2326
2335
|
canvas.setFilter(SHOW[show].statuses, { keepWaiting: Boolean(SHOW[show].waiting) });
|
|
2327
2336
|
timeline.setFilter(SHOW[show].statuses, { keepWaiting: Boolean(SHOW[show].waiting) });
|
|
2328
|
-
paintSummary(lastStats, lastStats?.malformed ? [
|
|
2337
|
+
paintSummary(lastStats, lastStats?.malformed ? [t('{n} malformed', { n: lastStats.malformed })] : []);
|
|
2329
2338
|
}
|
|
2330
2339
|
|
|
2331
2340
|
/* ------------------------------------------------------ needs attention */
|
|
@@ -2352,14 +2361,14 @@ function paintAttention(nodes) {
|
|
|
2352
2361
|
return b;
|
|
2353
2362
|
});
|
|
2354
2363
|
if (list.length > MAX) {
|
|
2355
|
-
const more = node('button', 'att',
|
|
2364
|
+
const more = node('button', 'att', t('+{n} more', { n: list.length - MAX }));
|
|
2356
2365
|
more.type = 'button';
|
|
2357
2366
|
more.addEventListener('click', () => setShow('attention'));
|
|
2358
2367
|
chips.push(more);
|
|
2359
2368
|
}
|
|
2360
2369
|
el.attention.replaceChildren(
|
|
2361
|
-
node('span', 'attention-label', 'Needs attention'), ...chips,
|
|
2362
|
-
node('span', 'row-fill'), node('span', 'sub attention-hint', 'Click one to go to it'),
|
|
2370
|
+
node('span', 'attention-label', t('Needs attention')), ...chips,
|
|
2371
|
+
node('span', 'row-fill'), node('span', 'sub attention-hint', t('Click one to go to it')),
|
|
2363
2372
|
);
|
|
2364
2373
|
}
|
|
2365
2374
|
|
|
@@ -2440,9 +2449,9 @@ function selectSession(sessionId) {
|
|
|
2440
2449
|
// used to share EventSource's 'error' event, so "no such session" was shown
|
|
2441
2450
|
// as "reconnecting" and the reason was thrown away.
|
|
2442
2451
|
source.addEventListener('fault', (e) => {
|
|
2443
|
-
let reason = 'unknown';
|
|
2452
|
+
let reason = t('unknown');
|
|
2444
2453
|
try { reason = JSON.parse(e.data).reason ?? reason; } catch { /* keep default */ }
|
|
2445
|
-
el.foot.textContent =
|
|
2454
|
+
el.foot.textContent = t('stream fault: {reason}', { reason });
|
|
2446
2455
|
source.close();
|
|
2447
2456
|
source = null;
|
|
2448
2457
|
});
|
|
@@ -2452,7 +2461,7 @@ function selectSession(sessionId) {
|
|
|
2452
2461
|
// does not decide Offline.
|
|
2453
2462
|
source.onerror = () => {
|
|
2454
2463
|
if (!source) return; // already closed by a fault
|
|
2455
|
-
el.foot.textContent = 'stream dropped — reconnecting';
|
|
2464
|
+
el.foot.textContent = t('stream dropped — reconnecting');
|
|
2456
2465
|
};
|
|
2457
2466
|
}
|
|
2458
2467
|
|
|
@@ -2581,7 +2590,7 @@ function sessionIsLive() {
|
|
|
2581
2590
|
/** The session card's state: waiting on the viewer outranks what the fleet says, as it does for an agent. */
|
|
2582
2591
|
function sessionStateNow() {
|
|
2583
2592
|
const own = waitingNow.some((r) => r.sessionId === current && !r.agentId);
|
|
2584
|
-
if (own) return { key: 'waiting', word: 'Needs you', tone: 'waiting', known: true };
|
|
2593
|
+
if (own) return { key: 'waiting', word: t('Needs you'), tone: 'waiting', known: true };
|
|
2585
2594
|
return sessionStatus(current, fleetData);
|
|
2586
2595
|
}
|
|
2587
2596
|
|
|
@@ -2639,7 +2648,7 @@ async function setSessionMode(sessionId, mode) {
|
|
|
2639
2648
|
} catch (e) {
|
|
2640
2649
|
res = { ok: false, reason: e.message };
|
|
2641
2650
|
}
|
|
2642
|
-
if (!res.ok) toast(
|
|
2651
|
+
if (!res.ok) toast(t('Mode not changed — {reason}', { reason: res.reason ?? t('no reason given') }));
|
|
2643
2652
|
else pollOwned();
|
|
2644
2653
|
}
|
|
2645
2654
|
|
|
@@ -2660,7 +2669,7 @@ async function decideRequest(item, verdict, extra = {}) {
|
|
|
2660
2669
|
decided.delete(item.key);
|
|
2661
2670
|
dock.release(item.key);
|
|
2662
2671
|
list.release(item.key);
|
|
2663
|
-
toast(
|
|
2672
|
+
toast(t('Decision not recorded — {reason}', { reason: res.reason ?? t('no reason given') }));
|
|
2664
2673
|
return;
|
|
2665
2674
|
}
|
|
2666
2675
|
answered.add(item.key);
|
|
@@ -2716,7 +2725,7 @@ chat.onPermissions = () => pollOwned();
|
|
|
2716
2725
|
|
|
2717
2726
|
let nextPollAt = null;
|
|
2718
2727
|
let restartNote = null; // staleServerNote(/api/health): the server runs older code than the page
|
|
2719
|
-
const stageNote = { text: node('span', 'stage-note-text'), retry: node('span', 'sub'), button: node('button', 'btn sm', 'Retry now') };
|
|
2728
|
+
const stageNote = { text: node('span', 'stage-note-text'), retry: node('span', 'sub'), button: node('button', 'btn sm', t('Retry now')) };
|
|
2720
2729
|
stageNote.button.type = 'button';
|
|
2721
2730
|
stageNote.button.addEventListener('click', () => retryNow());
|
|
2722
2731
|
el.stageNote.append(stageNote.text, stageNote.retry, node('span', 'row-fill'), stageNote.button);
|
|
@@ -2748,7 +2757,7 @@ function paintStageNote() {
|
|
|
2748
2757
|
/** The summary box, from the session on screen. A live session's time is counted to now, so this runs on the tick. */
|
|
2749
2758
|
function paintSpend() {
|
|
2750
2759
|
const usage = lastNodes?.find((n) => n.kind === 'session')?.usage ?? null;
|
|
2751
|
-
canvas.setSpend(summaryRows(usage, { live: lastLive === true, now: serverNow() }));
|
|
2760
|
+
canvas.setSpend(summaryRows(usage, { live: lastLive === true, now: serverNow(), agentRows: agentSpendRows(lastNodes) }));
|
|
2752
2761
|
}
|
|
2753
2762
|
|
|
2754
2763
|
function showGraph() {
|
|
@@ -2779,7 +2788,7 @@ function showGraph() {
|
|
|
2779
2788
|
// other 243 failed. A count of records that could not be read stays beside
|
|
2780
2789
|
// them: it is not a status, and it is not nothing.
|
|
2781
2790
|
const s = g.stats ?? {};
|
|
2782
|
-
paintSummary(s, s.malformed ? [
|
|
2791
|
+
paintSummary(s, s.malformed ? [t('{n} malformed', { n: s.malformed })] : []);
|
|
2783
2792
|
el.foot.textContent = '';
|
|
2784
2793
|
}
|
|
2785
2794
|
|
|
@@ -2799,7 +2808,7 @@ function paintTerminalWait() {
|
|
|
2799
2808
|
what.title = w.what ?? '';
|
|
2800
2809
|
el.terminalWait.replaceChildren(
|
|
2801
2810
|
node('span', 'terminal-wait-word', `${w.text}${w.what ? ':' : ''}`), what,
|
|
2802
|
-
node('span', 'sub terminal-wait-note', 'Studio answers only for sessions started here'),
|
|
2811
|
+
node('span', 'sub terminal-wait-note', t('Studio answers only for sessions started here')),
|
|
2803
2812
|
);
|
|
2804
2813
|
}
|
|
2805
2814
|
canvas.fitIfUntouched();
|
|
@@ -2820,7 +2829,7 @@ const afterSummary = el.summary.nextElementSibling;
|
|
|
2820
2829
|
function setDrawer(open) {
|
|
2821
2830
|
shell.classList.toggle('side-open', open);
|
|
2822
2831
|
el.menuBtn.setAttribute('aria-expanded', String(open));
|
|
2823
|
-
el.menuBtn.setAttribute('aria-label', open ? 'Close the navigator' : 'Open the navigator');
|
|
2832
|
+
el.menuBtn.setAttribute('aria-label', open ? t('Close the navigator') : t('Open the navigator'));
|
|
2824
2833
|
}
|
|
2825
2834
|
el.menuBtn.addEventListener('click', () => setDrawer(!shell.classList.contains('side-open')));
|
|
2826
2835
|
// Choosing a session in the drawer is why it was opened; it closes behind the choice.
|
|
@@ -2853,7 +2862,7 @@ async function pollFleet() {
|
|
|
2853
2862
|
try {
|
|
2854
2863
|
renderFleet(await getJson('/api/fleet'));
|
|
2855
2864
|
} catch (e) {
|
|
2856
|
-
renderNote(el.fleet, { kind: 'unmeasured', title: 'Server unreachable', why: e.message });
|
|
2865
|
+
renderNote(el.fleet, { kind: 'unmeasured', title: t('Server unreachable'), why: e.message });
|
|
2857
2866
|
}
|
|
2858
2867
|
}
|
|
2859
2868
|
|
|
@@ -2883,8 +2892,8 @@ getJson('/api/health').then((h) => {
|
|
|
2883
2892
|
const badge = document.getElementById('role');
|
|
2884
2893
|
badge.hidden = role !== 'viewer';
|
|
2885
2894
|
if (transcriptsHidden) {
|
|
2886
|
-
badge.textContent = 'Read-only · transcripts hidden';
|
|
2887
|
-
badge.title = 'This link reads the panel and changes nothing; no transcript text is sent to it';
|
|
2895
|
+
badge.textContent = t('Read-only · transcripts hidden');
|
|
2896
|
+
badge.title = t('This link reads the panel and changes nothing; no transcript text is sent to it');
|
|
2888
2897
|
}
|
|
2889
2898
|
if (role === 'owner') pollRemote(); else paintMachines();
|
|
2890
2899
|
}).catch(() => { /* the Live indicator says the server is unreachable */ });
|