Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_ai_workbench.js
T
DemonGatanjieuandClaude Fable 5.2 743a2bc33a Let the user hide a few words inside a text the AI reads
The gist of the prompt box and a message of a private task went to the AI
whole or not at all. The Hide button marks the selected words; seal_marked
turns the marked words into tokens when the text is handed in, and the
page shows them again as the words they stand for.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 19:06:44 +08:00

593 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The AI workbench page: what AI apps did through Anomalous's MCP endpoint (api/ai_tasks.py).
* The tasks on the left, newest first; on the right one task: its title and counts, a line
* counting what it made that opens the Results view (ui_ai_outputs.js), then its steps as a timeline,
* the AI's work in a row folded into one (ui_ai_chain.js), each step with what it made and where that leads (the picture
* in the viewer, the model's page, the combo, the canvas change found or undone). A task the
* AI is still working on grows by itself ("anomalous.ai_task" events) and says so at the foot.
* The column on the right shows one view, picked by the switch at the top right
* in the window's title bar (ui_ai_side_views.js): the stage with the panels the AI drew (ui_ai_side.js; a line in the
* timeline brings it there, its questions are answered there, a toast says so while the user is
* elsewhere), a run's shape at full size, the materials picker, or what the task made. The header stays on top while
* the timeline scrolls. The task list folds from the title bar's button and peeks from it.
* Under a task's title, the task it goes on from and those going on from it (ui_ai_task_links.js).
* A step's words are ui_ai_step_body.js's; a decision the AI records shows as a card and the
* side column's "Decisions" view lists all of the task's (ui_ai_decisions.js).
* A task opens at its newest step and stays there as steps come in while the user reads at the
* foot; read further up, it stays put and a button over the message box leads down (ui_ai_newest.js).
* Under the timeline the user can write to the AI on that task; the message waits until the AI
* asks (get_answers) or runs its next step, and says whether it has. The list groups the tasks
* by day, each with an icon for where it stands.
* Picking a task draws it at once from what is in hand (a copy read before or read ahead while the
* pointer rested on it, ai_task_cache.js; the activity entries known) and again, only what
* changed, once the server has answered.
* In a narrow window the list and the task take turns. The rules are in ai_task_view.js.
* Also the home page's "What the AI did lately" and the rail entry's dot for news.
*/
import { app } from '../../../scripts/app.js';
import { api } from '../../../scripts/api.js';
import { resolveLocale, translate as t } from './locales.js';
import { dayLabel, fetchActivity, timeLabel } from './activity_log.js';
import { canUndo } from './canvas_undo.js';
import { anomalousAlert } from './ui_dialog.js';
import { messageBox, updateMessageBox } from './ui_ai_message.js';
import { atFoot, newestButton } from './ui_ai_newest.js';
import { nowStrip } from './ui_ai_now.js';
import { taskCommands } from './ui_ai_commands.js';
import { hidePanelToast, panelToast } from './ui_ai_panel.js';
import { bringStage, focusPanel, openQuestions, panelLine, panelSteps, stageBadge, stageNews } from './ui_ai_side.js';
import { waitingView, welcomePage } from './ui_ai_ready.js';
import { gateCard } from './ui_ai_gate.js';
import { iconButton } from './ui_ai_icons.js';
import { listToggle, renderTaskList, stateIcon } from './ui_ai_task_list.js';
import { flowMap } from './ui_ai_flow.js';
import { drawRecentAiTasks } from './ui_ai_recent.js';
import { locateNode, openModel, resultLinks, updateRunProgress } from './ui_ai_results.js';
import { shared } from './ui_ai_shared.js';
import { drawOutputsLine, newOutputs } from './ui_ai_outputs.js';
import { compareBar, setMarker } from './ui_ai_images.js';
import { openMarker } from './ui_ai_marker.js';
import { toolboxCount } from './ui_ai_toolbox.js';
import { stepBody } from './ui_ai_step_body.js';
import { chainRow, looksRow, undoChanges } from './ui_ai_chain.js';
import { taskLinks } from './ui_ai_task_links.js';
import { TIMELINE_MIN, connectSide, drawSide, pickView, sideShown, sideSwitch, sideWidth, viewTabs } from './ui_ai_side_views.js';
import { executionStatus } from './ui_ai_status.js';
import { canvasControls } from './ui_ai_task_canvas.js';
import { privacyChip, privacyReach } from './ui_ai_privacy.js';
import { menuActions, openTaskMenu, taskMenu } from './ui_ai_task_menu.js';
import { syncRows } from './sync_rows.js';
import { keptTask, readAhead, readTask, stopReadAhead } from './ai_task_cache.js';
import {
activityIds, duration, groupSteps, stepDetail, taskCounts, taskResults, taskState, toolKey,
} from './ai_task_view.js';
const NARROW_PX = 720;
const SHORT_PX = 880; // a window this low (not maximised) keeps less room around the task's head
const TIGHT_PX = 640; // a task's column this narrow (the side column open) shows its head's chips as icons
const COLLAPSED_KEY = 'anomalous_ai_tasks_collapsed';
const RECENT_COUNT = 3;
const state = {
owner: null,
panel: null,
page: null,
tasks: [],
selected: null, // task id shown
pinned: false, // the user picked it: a new task of the AI does not take its place
view: null, // the shown task's parts kept between redraws (renderTask)
stage: { taskId: null, latestKey: null, atBottom: true, focus: null, tab: 'timeline', view: 'stage', mapStep: null, unseen: false, hidden: false, outputsSeen: null }, // ui_ai_side.js
numbers: new Map(), // step id -> its number in the timeline (the stage shows it)
tasksCollapsed: (() => { try { return localStorage.getItem(COLLAPSED_KEY) === '1'; } catch (_) { return false; } })(),
task: null, // its full record
entries: shared.entries, // activity entries of its canvas steps, for undo and locate (ui_ai_results.js)
narrowView: 'list',
refreshTimer: null,
request: 0, // newer navigation/refresh invalidates older responses
observer: null,
bar: null, // the ticked pictures' compare bar (ui_ai_images.js), made once
};
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, title) {
const node = el('button', className, label);
node.type = 'button';
node.onclick = onClick;
if (title) node.title = title;
return node;
}
const say = item => (item ? t(item.key, item.values) : '');
const visible = () => Boolean(state.panel?.isConnected && state.panel.style.display !== 'none');
async function getJson(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
async function postTask(id, body, action = '') {
const response = await fetch(`/anomalous/ai_tasks/${encodeURIComponent(id)}${action ? `/${action}` : ''}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.message || data.error || `HTTP ${response.status}`);
return data;
}
const fetchTasks = (limit = 100) => getJson(`/anomalous/ai_tasks?limit=${limit}`).then(data => data.tasks || []);
// ---------------------------------------------------------------- the list
function renderList(box) {
renderTaskList(box, {
tasks: state.tasks, selected: state.selected, onNew: newTask,
// Picking the newest task follows the AI again; an older one stays put.
onPick: (summary) => { state.pinned = summary.id !== state.tasks[0]?.id; void select(summary.id); },
onHover: (summary) => { if (summary.id !== state.selected) readAhead(summary.id); },
onLeave: stopReadAhead,
onMenu: async (anchor, summary) => {
const task = await readTask(summary.id).catch(error => void anomalousAlert(error.message));
if (task && anchor.isConnected) openTaskMenu(anchor, task, taskActions(task));
},
});
}
const taskActions = task => menuActions(task, { tasks: state.tasks, post: postTask, refresh, removed: () => {
if (state.selected !== task.id) return;
state.selected = null;
state.task = null;
state.narrowView = 'list';
} });
// ---------------------------------------------------------------- one task
/** One step of the timeline; `number` is its place in the task (the dot shows it). */
function stepRow(step, number) {
const running = step.state === 'running';
const row = el('div', `anomalous-ai-step is-${step.kind}${step.ok ? '' : ' is-failed'}${running ? ' is-running' : ''}${step.state === 'asking' ? ' is-asking' : ''}`);
row.dataset.stepIds = step.id;
row.append(el('span', 'anomalous-ai-step-dot', number));
const body = el('div', 'anomalous-ai-step-body');
const line = el('div', 'anomalous-ai-step-line');
line.append(el('span', 'anomalous-ai-kind', t(`aiKind_${step.kind}`)), el('span', 'anomalous-ai-step-title', t(toolKey(step.tool))));
const detail = ['panel', 'outside'].includes(step.kind) || step.tool === 'user_activity' ? '' : stepDetail(step); // their body says it
if (detail) {
const text = el('span', 'anomalous-ai-step-detail', detail);
text.title = detail;
line.append(text);
}
body.append(line, ...stepBody(step, { taskId: state.task?.id, owner: state.owner, entries: state.entries, numberOf: id => state.numbers.get(id), steps: state.task?.steps, sealed: state.task?.sealed }));
const other = step.reply_to || step.replied;
if (other) {
body.append(iconButton(`anomalous-ai-reply-link${step.replied ? ' is-replied' : ''}`, step.replied ? 'chat' : 'undo',
t(step.replied ? 'aiReplied' : 'aiReplyTo', { number: state.numbers.get(other) ?? '' }), () => showStep(state.view.timeline, other)));
}
if (step.kind === 'panel' && state.task) {
body.append(panelLine(step, () => showOnStage(step.panel.id)));
}
const gate = state.task && gateCard(step, state.task.id, () => void refresh());
if (gate) body.append(gate);
if (step.error && !gate) body.append(el('div', 'anomalous-ai-step-error', step.error));
if (step.state === 'cancelled') body.append(el('div', 'anomalous-ai-step-cancelled', t(step.stop === 'user' ? 'aiRunCancelledByYou' : 'aiRunCancelledByAi')));
if (running && step.kind !== 'run') body.append(el('div', 'anomalous-ai-step-running', t('aiStepRunning')));
const links = resultLinks(step, renderTask);
if (links) body.append(links);
const when = el('div', 'anomalous-ai-step-when');
when.append(el('span', '', timeLabel(step.at)));
const took = duration(step.ms);
if (took) when.append(el('span', '', say(took)));
row.append(body, when);
return row;
}
async function undoAll(task) {
if (await undoChanges(task.steps, state.entries, t('aiUndoAll'), 'aiUndoAllConfirm')) renderTask();
}
function renderTask() {
const box = state.page?.querySelector('.anomalous-ai-task');
if (!box) return;
const task = state.task;
if (!task) {
box.replaceChildren(el('div', 'anomalous-ai-empty-task', state.tasks.length ? t('aiPickTask') : ''));
state.owner?.headerTools?.replaceChildren();
return;
}
const keepBottom = atFoot(box);
const keepTop = box.scrollTop;
const summary = state.tasks.find(item => item.id === task.id) || {};
const status = taskState(summary);
// The task's parts stay in the page between redraws (the AI's steps come in all the time):
// only what changed is drawn again, so a scrolled strip or diagram and half-typed text stay.
let view = state.view;
const fresh = !view || view.taskId !== task.id || !box.contains(view.timeline);
if (fresh) {
view = state.view = {
taskId: task.id, header: el('div', 'anomalous-ai-task-head'), flow: el('div', 'anomalous-ai-flow-holder'),
made: el('div', 'anomalous-ai-made-holder'), summary: el('div', 'anomalous-ai-summary'),
timeline: el('div', 'anomalous-ai-timeline'),
message: messageBox(async (text, refs) => {
await postTask(task.id, { text, refs }, 'message');
await refresh();
box.scrollTo({ top: box.scrollHeight, behavior: 'smooth' }); // what one sent, in view
}, { task: () => state.task, commands: () => taskCommands({ task: () => state.task, undo: () => undoAll(state.task), newTask,
side: view => pickView(state.stage, view, false, state.task, renderTask, refresh), changed: refresh }),
mark: (image, sent) => openMarker(image, task.id, () => { sent(); void refresh(); }),
openMaterials: () => pickView(state.stage, 'toolbox', false, state.task, renderTask, () => void refresh()) }),
seen: (task.steps || []).length, newest: newestButton(box), now: nowStrip(), dock: el('div', 'anomalous-ai-dock'),
};
view.dock.append(view.newest, view.now, view.message); // at the foot of the column, in view while the timeline scrolls
view.summary.append(view.flow, view.made);
box.replaceChildren(view.header, view.summary, view.timeline, view.dock);
new ResizeObserver(() => {
for (const part of [view.header, view.dock]) part.classList.toggle('is-tight', box.clientWidth < TIGHT_PX);
}).observe(view.header);
}
// A new panel opens the stage; in a narrow window only one that asks something takes its tab, and not from under the user's typing.
const news = stageNews(task, state.stage);
const typing = state.page.contains(document.activeElement) && document.activeElement.matches('textarea, input:not([type]), input[type=text]');
bringStage(state.stage, news, state.page.classList.contains('is-tabbed') || state.page.classList.contains('is-narrow'), typing);
const asking = panelSteps(task).reduce((sum, step) => sum + openQuestions(step), 0);
const header = view.header;
const titleLine = el('div', 'anomalous-ai-task-title-line');
const name = el('div', 'anomalous-ai-task-name');
const title = el('h2', 'anomalous-ai-task-title', task.title || t('aiUntitled'));
title.title = title.textContent;
name.append(button('anomalous-ai-back', `‹ ${t('aiAllTasks')}`, () => { state.narrowView = 'list'; layout(); }), title, stateChip(status));
const tools = el('div', 'anomalous-ai-task-tools');
tools.append(executionStatus(task, refresh), canvasControls(task, () => void refresh(), () => state.owner?.close?.()));
if (task.private === true) tools.append(privacyChip(task, () => void refresh()));
if (taskResults(task).canvas.some(result => state.entries.has(result.activity) && canUndo(app, state.entries.get(result.activity)))) {
tools.append(iconButton('anomalous-ai-tool', 'undo', t('aiUndoAll'), () => undoAll(task)));
}
tools.append(taskMenu(task, taskActions(task)));
const shown = sideShown(state.stage, task) && !(state.page.classList.contains('is-tabbed') && state.stage.tab !== 'stage');
titleLine.append(name, tools);
const unseen = newOutputs(state.stage, task, shown && state.stage.view === 'outputs');
const badges = { stage: stageBadge(task, state.stage, shown && state.stage.view === 'stage'), asking, toolbox: toolboxCount(task), outputs: unseen };
state.tabs = () => viewTabs(state.stage, shown, badges, view => pickView(state.stage, view, false, task, renderTask, () => void refresh()), showTimeline);
state.owner?.headerTools?.replaceChildren(...(state.page.classList.contains('uses-tabs') ? [] : [sideSwitch(state.stage, shown, badges,
view => pickView(state.stage, view, shown, task, renderTask, () => void refresh()))]));
const meta = [task.client, `${dayLabel(task.started)} ${timeLabel(task.started)}`, t('aiStepCount', { count: (task.steps || []).length }),
...taskCounts(summary).map(say)].filter(Boolean);
const said = el('div', 'anomalous-ai-task-meta', meta.join(' · '));
said.title = said.textContent;
const sub = el('div', 'anomalous-ai-task-sub');
sub.append(said, taskLinks(task, { open: (id) => { state.pinned = true; void select(id); }, changed: refresh }));
header.replaceChildren(state.tabs(), titleLine, sub,
...(task.stale_tools ? [el('div', 'anomalous-ai-task-stale', t('aiStaleTools'))] : []), ...privacyReach(task)); // mcp_directory.stale
const flow = flowMap(task, stepId => showStep(view.timeline, stepId));
view.flow.replaceChildren(...(flow ? [flow] : []));
drawOutputsLine(view.made, task, () => pickView(state.stage, 'outputs', false, state.task, renderTask, () => void refresh()));
// Numbered in the order they happened, so the timeline reads 1, 2, 3 from the top.
const items = groupSteps(task.steps);
const rows = items.map((item, at) => {
if (item.type === 'looks') return { key: JSON.stringify(['looks', at, item.steps]), make: () => looksRow(item.steps, at + 1) };
if (item.type === 'step') return { key: JSON.stringify(['step', at, item.step, undoable(item.step)]), make: () => stepRow(item.step, at + 1) };
const live = at === items.length - 1 && !task.ended; // the AI may still add to it
return { key: JSON.stringify(['chain', at, item.steps, item.steps.map(undoable), live]),
make: () => chainRow(item.steps, at + 1, { entries: state.entries, stepRow, changed: renderTask, live }) };
});
state.numbers = new Map(items.flatMap((item, at) => (item.type === 'step' ? [item.step] : item.steps).map(step => [step.id, at + 1])));
// A task the user started waits for an AI: how to get one on it (what they sent meanwhile is drawn there).
const preparing = Boolean(task.waiting) && items.every(item => item.type === 'step' && item.step.kind === 'user');
if (preparing) {
const connected = state.tasks.some(item => item.client);
rows.splice(0, rows.length, { key: JSON.stringify(['waiting', task.title, task.steps, connected]), make: () => waitingView(task, {
connected, ready: items.map((item, at) => stepRow(item.step, at + 1)),
}) });
} else if (!items.length) rows.push({ key: 'empty', make: () => el('div', 'anomalous-ai-empty-task', t('aiNoSteps')) });
syncRows(view.timeline, rows);
updateMessageBox(view.message, Boolean(task.ended), preparing ? 'waiting' : status, toolboxCount(task));
view.now.update(task, { refresh: () => void refresh(), showStep: id => showStep(view.timeline, id),
openStage: () => pickView(state.stage, 'stage', false, task, renderTask, () => void refresh()) });
view.timeline.classList.toggle('is-waiting', preparing);
// Opened, at its newest step (a page being prepared, at its top); read at the foot, kept there.
if (!view.newest.jumping) box.scrollTop = fresh && preparing ? 0 : fresh || keepBottom ? box.scrollHeight : keepTop;
view.newest.arrived((task.steps || []).slice(view.seen).filter(step => step.kind !== 'user').length);
view.seen = (task.steps || []).length;
drawStage(task, news.arrived);
}
/** Brings a step's row into view (from the flow), marked for a moment; a folded chain opens to it. */
function showStep(timeline, stepId) {
const has = item => item.dataset.stepIds.split(' ').includes(stepId);
const chain = [...timeline.children].find(has);
chain?.reveal?.();
const row = [...timeline.querySelectorAll('[data-step-ids]')].findLast(has);
if (!row) return;
row.scrollIntoView({ block: 'center', behavior: 'smooth' });
row.classList.add('is-flash');
setTimeout(() => row.classList.remove('is-flash'), 1600);
}
/** The side column beside the timeline: the view picked (ui_ai_side_views.js). */
function drawStage(task, arrived = null) {
const box = state.page?.querySelector('.anomalous-ai-stage');
if (!box) return;
state.page.classList.toggle('has-stage', sideShown(state.stage, task));
drawSide(box, task, state.stage, {
taskId: task.id,
owner: state.owner,
openPicture: (url, video) => state.owner?.showGalleryViewer?.(url, video),
openModel,
locateNode: id => locateNode(id, null),
stepOf: id => task.steps.find(item => item.id === id),
changed: () => void refresh(),
numberOf: id => state.numbers.get(id) || '',
showCanvas: () => state.owner?.close?.(),
close: () => {
state.stage.hidden = true;
state.stage.tab = 'timeline';
renderTask();
},
tabs: state.tabs,
}, arrived);
layout();
}
function stateChip(status) {
const chip = el('span', `anomalous-ai-state is-${status}`);
chip.append(stateIcon(status, 12), el('span', '', t(`aiState_${status}`)));
return chip;
}
function showOnStage(panelId) {
if (!state.task) return;
focusPanel(state.stage, panelId);
renderTask();
}
/** New task: the user starts one; the next AI app that calls takes it over. What it is for is said on its page. */
async function newTask() {
try {
const response = await fetch('/anomalous/ai_tasks', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: '', from: state.task?.id }), // goes on from the task shown
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || data.error || `HTTP ${response.status}`);
state.selected = data.task.id;
state.pinned = false;
state.narrowView = 'task';
await refresh();
} catch (error) {
await anomalousAlert(error.message);
}
}
function showTimeline() {
state.stage.tab = 'timeline';
renderTask();
}
/** The title bar's list button: folds or shows the task list (in a narrow window, goes to it and back). */
function toggleTasks() {
if (state.page?.classList.contains('is-narrow')) {
state.narrowView = state.narrowView === 'list' && state.selected ? 'task' : 'list';
return layout();
}
const folded = !state.tasksCollapsed;
state.tasksCollapsed = folded;
try { localStorage.setItem(COLLAPSED_KEY, folded ? '1' : '0'); } catch (_) { /* this viewer's convenience */ }
layout();
}
/** Which canvas changes of a step can be undone now (part of when its row is drawn again). */
function undoable(step) {
return (step.results || []).filter(result => result.type === 'canvas')
.map(result => [state.entries.get(result.activity) || null, state.entries.has(result.activity) && canUndo(app, state.entries.get(result.activity))]);
}
const unknownEntries = new Set(); // activity ids the log no longer has: not asked for again
/** The activity entries of `task`'s canvas steps already in hand. */
function keptEntries(task) {
const wanted = new Set(activityIds(task));
return new Map([...state.entries].filter(([id]) => wanted.has(id)));
}
/** Those and the missing ones from the activity log; null when none was missing. */
async function missingEntries(task) {
const kept = keptEntries(task);
const missing = activityIds(task).filter(id => !kept.has(id) && !unknownEntries.has(id));
if (!missing.length) return null;
const entries = await fetchActivity({ limit: 500 });
for (const entry of entries) if (missing.includes(entry.id)) kept.set(entry.id, entry);
for (const id of missing) if (!kept.has(id)) unknownEntries.add(id);
return kept;
}
function showTask(task, entries) {
state.task = task;
shared.entries = state.entries = entries;
shared.taskId = task?.id || null;
renderTask();
}
/** Task `id` as the server has it now, drawn; then again once the activity entries it lacked came. */
async function showRead(id, request, otherwise) {
const current = () => state.selected === id && request === state.request;
const task = await readTask(id).catch(() => otherwise);
if (!current()) return;
showTask(task, task ? keptEntries(task) : new Map());
const entries = task ? await missingEntries(task).catch(() => null) : null;
if (entries && current()) showTask(task, entries);
}
async function select(id) {
const request = ++state.request;
state.selected = id;
state.narrowView = 'task';
renderList(state.page.querySelector('.anomalous-ai-tasks'));
layout();
// A task read before shows at once; what changed since follows.
const known = keptTask(id);
if (known) showTask(known, keptEntries(known));
await showRead(id, request, known);
}
/** Pictures on the AI page can be marked for the task shown (ui_ai_marker.js). */
setMarker(image => state.task && openMarker(image, state.task.id, () => void refresh()));
/**
* The materials picker docks in the side column while this page is shown; a run's step opens its shape there.
* Connected when the page is first drawn, not when this file loads: ui_ai_toolbox.js is in an import cycle with
* this file and ComfyUI loads every file of the plugin as an extension, so either may run first.
*/
function connectSideOnce() {
shared.openMap ||= connectSide({
side: state.stage, task: () => state.task, redraw: () => renderTask(),
box: () => (visible() && state.page?.querySelector('.anomalous-ai-stage')) || null,
});
}
// ---------------------------------------------------------------- the page
function layout() {
const page = state.page;
if (!page) return;
const width = page.clientWidth;
const narrow = width > 0 && width < NARROW_PX;
const folded = state.tasksCollapsed && !narrow;
page.classList.toggle('is-narrow', narrow);
page.classList.toggle('is-short', page.clientHeight > 0 && page.clientHeight < SHORT_PX);
page.classList.toggle('shows-task', narrow && state.narrowView === 'task' && Boolean(state.selected));
page.classList.toggle('peeks', narrow && state.narrowView !== 'task' && Boolean(state.selected)); // the list over the task
page.classList.toggle('tasks-collapsed', folded);
// Three columns when there is room; else the timeline and the stage take turns behind tabs.
const room = width - (narrow || folded ? 0 : 260);
const noRoom = narrow || room < sideWidth() + TIMELINE_MIN;
const tabbed = page.classList.contains('has-stage') && noRoom;
const changed = page.classList.contains('uses-tabs') !== noRoom;
page.classList.toggle('uses-tabs', noRoom);
page.classList.toggle('is-tabbed', tabbed);
page.classList.toggle('shows-stage', tabbed && state.stage.tab === 'stage');
if (changed && state.task) renderTask(); // the title bar's switch and the tabs change places
}
async function refresh() {
if (!state.page) return;
const request = ++state.request;
const tasks = await fetchTasks().catch(() => null);
if (!tasks || !state.page || request !== state.request) return;
state.tasks = tasks;
if (!tasks.length) {
state.page.replaceChildren(welcomePage(newTask), state.bar);
state.owner?.headerTools?.replaceChildren();
state.page.classList.add('is-empty');
return;
}
if (state.page.classList.contains('is-empty') || !state.page.querySelector('.anomalous-ai-tasks')) {
state.page.classList.remove('is-empty');
const scrim = button('anomalous-ai-scrim', '', () => { state.narrowView = 'task'; layout(); });
scrim.title = t('aiBackToTask');
state.page.replaceChildren(el('aside', 'anomalous-ai-tasks'), el('section', 'anomalous-ai-task'), el('section', 'anomalous-ai-stage'), scrim, state.bar);
}
// The newest task while the AI works on it, unless the user picked another one.
const follow = !state.pinned && !tasks[0].ended && tasks[0].id !== state.selected
&& (tasks[0].live || tasks[0].started > (state.task?.started || 0));
if (follow || !tasks.some(task => task.id === state.selected)) {
state.selected = tasks[0].id;
state.pinned = false;
state.task = null;
}
renderList(state.page.querySelector('.anomalous-ai-tasks'));
await showRead(state.selected, request, state.task?.id === state.selected ? state.task : null); // a failed read keeps what shows
layout();
}
// Poll only while this view is visible: reconnects and missed websocket events must not freeze its status.
let pollTimer = null;
function pollVisible() {
clearTimeout(pollTimer);
pollTimer = setTimeout(async () => {
if (document.hidden || !state.page?.isConnected || !visible()
|| !state.owner?.modal?.classList.contains('visible') || state.owner.currentShellPage?.() !== 'ai') return;
try { await refresh(); } finally { pollVisible(); }
}, 5000);
}
/** The whole page into `panel`. */
export function renderAiWorkbench(owner, panel) {
connectSideOnce();
if (state.language !== resolveLocale()) { // shown again in another language: the parts kept between redraws go
state.language = resolveLocale();
state.view = null;
state.page?.querySelectorAll('[data-key], [data-panel-key]').forEach(node => { delete node.dataset.key; delete node.dataset.panelKey; });
}
state.owner = owner;
shared.owner = owner;
state.panel = panel;
state.bar ||= compareBar();
if (!state.page || state.page.parentNode !== panel) {
state.observer?.disconnect();
state.page = el('div', 'anomalous-ai');
panel.replaceChildren(state.page);
if (typeof ResizeObserver === 'function') {
state.observer = new ResizeObserver(layout);
state.observer.observe(state.page);
}
}
owner.headerLeadTools?.replaceChildren(listToggle(state.page, toggleTasks));
owner.railNews?.('ai', false);
hidePanelToast();
void refresh();
pollVisible();
}
/** Once: the server's "anomalous.ai_task" event refreshes the page, the home list and the rail's dot. */
export function watchAiTasks(owner) {
state.owner = owner;
shared.owner = owner;
document.addEventListener('visibilitychange', () => { if (!document.hidden) pollVisible(); });
api.addEventListener('anomalous.ai_task', (event) => {
const detail = event?.detail || {};
const onPage = () => owner.modal?.classList.contains('visible') && owner.currentShellPage?.() === 'ai' && visible();
// A run's progress only moves its bar (every couple of seconds while it runs): no refetch.
if (detail.progress) {
const step = state.task?.id === detail.id && state.task.steps.find(item => item.id === detail.progress.step);
if (step) step.progress = detail.progress;
if (onPage() && step) {
updateRunProgress(state.page, detail.progress);
state.view?.header.querySelector('.anomalous-ai-execution')?.replaceWith(executionStatus(state.task, refresh));
state.view?.now.update(state.task); // the strip over the message box says the same
}
return;
}
if (detail.panel && !onPage()) panelToast(owner, String(detail.panel), Boolean(detail.asks));
else if (detail.gate && !onPage()) panelToast(owner, t(`aiGateQ_${detail.gate}`), true);
clearTimeout(state.refreshTimer);
state.refreshTimer = setTimeout(() => {
if (onPage()) void refresh();
else owner.railNews?.('ai', true);
if (owner.currentShellPage?.() === 'home') owner.onActivityRecorded?.();
}, 300);
});
}
/** Opens task `id` on the AI page (from the activity log, the home page). */
export function openAiTask(owner, id, newest = null) {
state.selected = id;
state.pinned = id !== newest;
owner.goTo('ai');
}
/** The home page's short list (ui_ai_recent.js); a row opens its task here. */
export function renderRecentAiTasks(owner, container) {
drawRecentAiTasks(owner, container, {
fetchTasks: () => fetchTasks(RECENT_COUNT),
open: (summary, newest) => openAiTask(owner, summary.id, newest),
});
}