Files
DemonGatanjieuandClaude Opus 5.5 0de22f8f46 AI workbench: how to change a marked picture, a quicker task list, "AI edits a copy", lit source rows
- Marker: "make a repaint mask" becomes How to change it (leave it to the AI, only the
  marked areas, the whole picture anew); the AI reads the choice first as the user's, the
  timeline row says it, the repaint brush picks "only the marked areas".
- Task list: folded, it stays laid out out of sight and slides in at once from its button
  or the page's left edge; redraws make anew only the rows that changed (sync_rows.js).
  A picked task shows at once from the copy read before or read ahead on hover
  (ai_task_cache.js), then what changed; activity entries follow instead of holding it up.
  Stage pictures load as thumbnails first, the file itself only when shown larger.
- Header: "Own tab" is "AI edits a copy ▾", a two-way menu with what each means (kept open
  across redraws), and "Show the copy".
- Materials: the Source list lights the hovered row and ticks the one shown (a later rule
  had hidden both), tooltips only for cut names, arrows and Enter (ui_ai_facet_menu.js).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:08:46 +08:00

114 lines
5.2 KiB
JavaScript

/**
* Where the AI works on the canvas for a task (api/ai_task_canvas.py), in the task's header: a
* copy (Anomalous copies the open workflow into a tab of the task's own before the AI's first
* change) or the open canvas. The chip says which ("AI edits a copy ▾") and opens the two to pick
* from, each with what it means (Esc or a press elsewhere closes it); once the task has its copy,
* "Show the copy" brings that tab to the front and shows the canvas (mcp_task_tab.js).
*/
import { translate as t } from './locales.js';
import { anomalousAlert } from './ui_dialog.js';
import { icon } from './ui_ai_icons.js';
import { showTab } from './mcp_task_tab.js';
const MODES = ['new', 'current'];
// The header is drawn anew as the task changes: the list open for a task opens again with it.
let openFor = null; // the id of the task whose list is open
let dropOpen = null; // takes that list and its listeners away
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
async function setMode(task, mode) {
const response = await fetch(`/anomalous/ai_tasks/${encodeURIComponent(task.id)}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ canvas: mode }),
});
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || response.statusText);
}
function closeMenu() {
openFor = null;
dropOpen?.();
}
/** The two ways to pick from, under the chip `anchor` in `box`; `changed()` after a new one is set. */
function modeMenu(box, anchor, task, mode, changed, focus = true) {
dropOpen?.();
const menu = el('div', 'anomalous-ai-canvas-menu');
menu.dataset.anomalousEscape = '1'; // Esc closes this list, not the browser
menu.setAttribute('role', 'menu');
for (const choice of MODES) {
const item = el('button', `anomalous-ai-canvas-choice${choice === mode ? ' is-on' : ''}`);
item.type = 'button';
item.setAttribute('role', 'menuitemradio');
item.setAttribute('aria-checked', String(choice === mode));
const words = el('span', 'anomalous-ai-canvas-choice-words');
words.append(el('span', 'anomalous-ai-canvas-choice-name', t(`aiCanvasPick_${choice}`)), el('span', 'anomalous-ai-canvas-choice-why', t(`aiCanvasPickWhy_${choice}`)));
item.append(icon(choice === mode ? 'check' : '', 15), words);
item.onclick = async () => {
closeMenu();
if (choice === mode) return;
anchor.disabled = true;
try {
await setMode(task, choice);
} catch (error) {
anchor.disabled = false;
await anomalousAlert(error.message);
return;
}
changed();
};
menu.append(item);
}
function drop() {
menu.remove();
anchor.setAttribute('aria-expanded', 'false');
document.removeEventListener('pointerdown', outside, true);
document.removeEventListener('keydown', escape);
if (dropOpen === drop) dropOpen = null;
}
const outside = (event) => { if (!menu.contains(event.target) && !anchor.contains(event.target)) closeMenu(); };
const escape = (event) => { if (event.key === 'Escape') setTimeout(closeMenu); }; // after the browser's Esc saw the mark
box.append(menu);
anchor.setAttribute('aria-expanded', 'true');
if (focus) menu.querySelector('.is-on')?.focus({ preventScroll: true });
setTimeout(() => { if (dropOpen === drop) document.addEventListener('pointerdown', outside, true); });
document.addEventListener('keydown', escape);
dropOpen = drop;
openFor = task.id;
}
/** The header's canvas controls for `task`; `changed()` after the mode changed, `showCanvas()` reveals the canvas. */
export function canvasControls(task, changed, showCanvas) {
const box = el('div', 'anomalous-ai-canvas');
const mode = task.canvas_mode === 'current' ? 'current' : 'new';
const chip = el('button', `anomalous-ai-tool anomalous-ai-canvas-mode is-${mode}`);
chip.type = 'button';
chip.title = t(`aiCanvasHint_${mode}`);
chip.setAttribute('aria-haspopup', 'menu');
chip.setAttribute('aria-expanded', 'false');
chip.append(icon(mode === 'new' ? 'copy' : 'page', 15), el('span', '', t(`aiCanvas_${mode}`)), icon('chevronDown', 13));
chip.onclick = () => (openFor === task.id && dropOpen ? closeMenu() : modeMenu(box, chip, task, mode, changed));
box.append(chip);
const tab = task.canvas?.tab;
if (mode === 'new' && tab?.path) {
const go = el('button', 'anomalous-ai-canvas-tab');
go.type = 'button';
go.title = t('aiCanvasGo', { name: tab.name });
go.append(icon('arrowRight', 13), el('span', 'anomalous-ai-canvas-tab-name', t('aiCanvasGoShort')));
go.onclick = async () => {
if (await showTab(tab.path)) showCanvas();
else await anomalousAlert(t('aiCanvasGone', { name: tab.name }));
};
box.append(go);
}
if (openFor === task.id) modeMenu(box, chip, task, mode, changed, false);
else if (openFor) closeMenu(); // another task is shown now
return box;
}