- api/action_catalog.py: every MCP tool's kind, stage of the work and gate in one place (a test
keeps it and the tools in step); steps now carry their stage.
- api/ai_task_changes.py: changes the user makes in Anomalous while an AI works become steps of
its task, reach the AI once with its next call (kind "change": keep it), and the activity
entry names the task; the activity page links to it ("AI task ->").
- api/ai_task_runs.py: run watching moved out of ai_tasks.py (600-line rule); a step a restart
left "running" is marked stopped when the record is read again.
- Pictures: inspector / compare with canvas / keep as tools on hover instead of three buttons
under every picture; tick any two (any step or task) and compare from the bar at the foot of
the page; the results strip shows thumbnails again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
219 lines
9.3 KiB
JavaScript
219 lines
9.3 KiB
JavaScript
/**
|
|
* The activity page (everything Anomalous changed, by day, with filters, details,
|
|
* "find on canvas" and "open" for scanned models) and the home page's "recent" list.
|
|
* Reads through activity_log.js; each render cancels the previous one's request.
|
|
*/
|
|
|
|
import { app } from "../../../scripts/app.js";
|
|
import { translate as t } from './locales.js';
|
|
import { changeLine, clearActivity, dayLabel, entrySummary, fetchActivity, fileDetailLines, timeLabel } from './activity_log.js';
|
|
import { openListedModel } from './ui_scan_lists.js';
|
|
import { changeNodeId } from './activity_diff.js';
|
|
import { anomalousConfirm } from './ui_dialog.js';
|
|
import { canUndo, undoEntry } from './canvas_undo.js';
|
|
import { newestCompareButton } from './ui_param_compare.js';
|
|
import { canvasChanges } from './ui_canvas_changes.js';
|
|
import { openAiTask } from './ui_ai_workbench.js';
|
|
|
|
const FILTERS = [['', 'activityAll'], ['canvas', 'activityCanvas'], ['file', 'activityFiles']];
|
|
const PAGE_SIZE = 300;
|
|
const RECENT_COUNT = 5;
|
|
let pageController = null;
|
|
let recentController = null;
|
|
let filter = '';
|
|
|
|
function el(tag, className, text) {
|
|
const node = document.createElement(tag);
|
|
if (className) node.className = className;
|
|
if (text !== undefined) node.textContent = text;
|
|
return node;
|
|
}
|
|
|
|
function button(className, label, onClick) {
|
|
const node = el('button', className, label);
|
|
node.type = 'button';
|
|
node.onclick = onClick;
|
|
return node;
|
|
}
|
|
|
|
/** Selects and centers the node a change names, if it is on the canvas now. */
|
|
function locateNode(change, note) {
|
|
const id = changeNodeId(change);
|
|
const graph = app.canvas?.graph || app.graph;
|
|
const node = id === null ? null : graph?.getNodeById?.(Number(id)) ?? graph?.getNodeById?.(id);
|
|
if (!node) {
|
|
note.textContent = t('activityLocateMissing');
|
|
return;
|
|
}
|
|
app.canvas.selectNode?.(node);
|
|
app.canvas.centerOnNode?.(node);
|
|
app.canvas.setDirty?.(true, true);
|
|
}
|
|
|
|
/** What an entry opens to: [{text, label?, onClick?}]. */
|
|
function entryLines(entry, owner, note) {
|
|
if (entry.source !== 'canvas') {
|
|
return fileDetailLines(entry).map(line => (line.model && owner ? {
|
|
text: line.text,
|
|
label: t('scanRowOpen'),
|
|
onClick: () => openListedModel(owner, line.model, () => owner.goTo('activity')),
|
|
} : line));
|
|
}
|
|
return (entry.detail?.changes || []).filter(change => change.kind !== 'opened').map(change => ({
|
|
text: changeLine(change),
|
|
...(change.kind !== 'removed' ? { label: t('activityLocate'), onClick: () => locateNode(change, note) } : {}),
|
|
}));
|
|
}
|
|
|
|
function renderEntry(entry, { compact = false, owner = null } = {}) {
|
|
const row = el('div', `anomalous-activity-entry is-${entry.source === 'canvas' ? 'canvas' : 'file'}`);
|
|
const head = el('div', 'anomalous-activity-head');
|
|
const summary = entrySummary(entry);
|
|
head.append(el('span', 'anomalous-activity-time', timeLabel(entry.time)),
|
|
el('span', 'anomalous-activity-source', t(entry.source === 'canvas' ? 'activityCanvas' : 'activityFiles')),
|
|
el('span', 'anomalous-activity-summary', summary));
|
|
head.title = summary;
|
|
row.appendChild(head);
|
|
if (compact) return row;
|
|
// Only what can still be undone offers it (canvas_undo.js).
|
|
if (entry.source === 'canvas' && canUndo(app, entry)) {
|
|
const undo = button('anomalous-activity-undo', t('activityUndo'), (event) => {
|
|
event.stopPropagation();
|
|
let done = t('activityUndone');
|
|
try { undoEntry(app, entry); } catch (error) { done = t('activityUndoGone'); }
|
|
undo.replaceWith(el('span', 'anomalous-activity-undone', done));
|
|
});
|
|
undo.onkeydown = (event) => event.stopPropagation();
|
|
head.appendChild(undo);
|
|
}
|
|
// Made by an AI, or by the user while one worked: its task on the AI workbench.
|
|
if (entry.task && owner) {
|
|
const task = button('anomalous-activity-ai-task', t('activityAiTask'), (event) => {
|
|
event.stopPropagation();
|
|
openAiTask(owner, entry.task);
|
|
});
|
|
task.onkeydown = (event) => event.stopPropagation();
|
|
head.appendChild(task);
|
|
}
|
|
const note = el('div', 'anomalous-activity-note');
|
|
const lines = entryLines(entry, owner, note);
|
|
if (!lines.length) return row;
|
|
|
|
// An entry with details opens to one line each.
|
|
head.classList.add('is-expandable');
|
|
head.tabIndex = 0;
|
|
head.setAttribute('role', 'button');
|
|
head.setAttribute('aria-expanded', 'false');
|
|
const details = el('div', 'anomalous-activity-details');
|
|
details.hidden = true;
|
|
if (entry.source === 'canvas') details.append(canvasChanges(entry, { locate: change => locateNode(change, note) }));
|
|
for (const { text, label, onClick } of entry.source === 'canvas' ? [] : lines) {
|
|
const line = el('div', 'anomalous-activity-change');
|
|
line.appendChild(el('span', 'anomalous-activity-change-text', text));
|
|
line.title = text;
|
|
if (onClick) line.appendChild(button('anomalous-activity-locate', label, onClick));
|
|
details.appendChild(line);
|
|
}
|
|
const shown = entry.source === 'canvas' ? lines.length : (entry.detail?.files || []).length;
|
|
const total = entry.detail?.total || shown;
|
|
if (entry.source !== 'canvas' && total > shown) details.appendChild(el('div', 'anomalous-activity-more', t('activityMore', { count: total - shown })));
|
|
details.appendChild(note);
|
|
const toggle = () => {
|
|
details.hidden = !details.hidden;
|
|
head.setAttribute('aria-expanded', String(!details.hidden));
|
|
};
|
|
head.onclick = toggle;
|
|
head.onkeydown = (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
toggle();
|
|
}
|
|
};
|
|
row.appendChild(details);
|
|
return row;
|
|
}
|
|
|
|
function renderList(list, entries, owner) {
|
|
list.replaceChildren();
|
|
let day = null;
|
|
for (const entry of entries) {
|
|
const label = dayLabel(entry.time);
|
|
if (label !== day) {
|
|
day = label;
|
|
list.appendChild(el('div', 'anomalous-activity-day', label));
|
|
}
|
|
list.appendChild(renderEntry(entry, { owner }));
|
|
}
|
|
}
|
|
|
|
/** The whole page into `panel`. */
|
|
export function renderActivityPage(owner, panel) {
|
|
owner.flushCanvasActivity?.();
|
|
pageController?.abort();
|
|
pageController = new AbortController();
|
|
const { signal } = pageController;
|
|
|
|
const page = el('div', 'anomalous-activity');
|
|
const bar = el('div', 'anomalous-activity-bar');
|
|
const chips = el('div', 'anomalous-activity-filters');
|
|
const list = el('div', 'anomalous-activity-list');
|
|
const load = () => {
|
|
list.replaceChildren(el('div', 'anomalous-activity-empty', t('loading')));
|
|
fetchActivity({ limit: PAGE_SIZE, source: filter, signal })
|
|
.then(entries => {
|
|
if (signal.aborted) return;
|
|
if (entries.length) renderList(list, entries, owner);
|
|
else list.replaceChildren(el('div', 'anomalous-activity-empty', t('activityEmpty')));
|
|
})
|
|
.catch((error) => {
|
|
if (signal.aborted) return;
|
|
list.replaceChildren(el('div', 'anomalous-activity-empty', `${t('activityLoadFailed')} ${error.message}`));
|
|
});
|
|
};
|
|
for (const [value, key] of FILTERS) {
|
|
const chip = button(`anomalous-activity-chip${value === filter ? ' active' : ''}`, t(key), () => {
|
|
filter = value;
|
|
chips.querySelectorAll('.anomalous-activity-chip').forEach(node => node.classList.toggle('active', node === chip));
|
|
load();
|
|
});
|
|
chips.appendChild(chip);
|
|
}
|
|
const clear = button('anomalous-activity-clear', t('activityClear'), async () => {
|
|
if (!await anomalousConfirm(t('activityClearConfirm'), t('activityTitle'))) return;
|
|
try {
|
|
await clearActivity();
|
|
load();
|
|
} catch (error) {
|
|
list.prepend(el('div', 'anomalous-activity-empty', `${t('activityLoadFailed')} ${error.message}`));
|
|
}
|
|
});
|
|
bar.append(chips, newestCompareButton('anomalous-activity-newest'), clear);
|
|
page.append(el('p', 'anomalous-activity-lead', t('activityLead')), bar, list);
|
|
panel.replaceChildren(page);
|
|
load();
|
|
}
|
|
|
|
/** The home page's short list: the latest few entries and a link to the page. */
|
|
export function renderRecentActivity(owner, container) {
|
|
recentController?.abort();
|
|
recentController = new AbortController();
|
|
const { signal } = recentController;
|
|
const section = el('section', 'anomalous-home-recent');
|
|
const head = el('div', 'anomalous-home-recent-head');
|
|
head.append(el('span', 'anomalous-home-recent-title', t('activityRecent')),
|
|
button('anomalous-home-link', t('activityViewAll'), () => owner.goTo('activity')));
|
|
const list = el('div', 'anomalous-activity-list is-compact');
|
|
section.append(head, list);
|
|
container.appendChild(section);
|
|
owner.flushCanvasActivity?.();
|
|
fetchActivity({ limit: RECENT_COUNT, signal })
|
|
.then((entries) => {
|
|
if (signal.aborted) return;
|
|
if (!entries.length) list.appendChild(el('div', 'anomalous-activity-empty', t('activityEmpty')));
|
|
for (const entry of entries) list.appendChild(renderEntry(entry, { compact: true }));
|
|
})
|
|
.catch(() => {
|
|
if (!signal.aborted) section.remove(); // the home page works without it
|
|
});
|
|
}
|