Open AI tasks at their newest step and keep the grip off the scrollbar

A task now opens at its newest step instead of where the previous task
was scrolled to, and a timeline read at its foot follows new steps
whatever the task's state (before, only while a step was running).
Read further up, it stays put; a round button over the message box
leads back down, with the count of the AI's new steps on its corner.
It replaces the "new steps below" pill (ui_ai_newest.js).

The side column's grip lay over the timeline's scrollbar, so grabbing
the scrollbar often resized the column instead. It now has a gap of its
own beside the scrollbar, drawn as three dots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-24 02:13:31 +08:00
co-authored by Claude Opus 5.5
parent 0930d07d4f
commit 09136eabb5
10 changed files with 122 additions and 74 deletions
+2 -2
View File
@@ -274,8 +274,8 @@ covered by the `styles.css` manifest.
and the rail's dot (`owner.railNews`). A task's parts stay in the page between redraws: a
step is drawn again only when it changed (`syncRows`), so a scrolled diagram or strip and
half-typed text survive the AI's steps coming in. `ui_ai_message.js` is the message box, made
once per task and docked at the foot of its column (in view while the timeline scrolls, with a
"new steps below" button): a growing input, the gallery and materials buttons beside it, and
once per task and docked at the foot of its column (in view while the timeline scrolls; a task
opens at its newest step, and `ui_ai_newest.js` is the button back to it over the box): a growing input, the gallery and materials buttons beside it, and
over it what the message carries (`ui_ai_tray.js`: pictures to mark up with
`ui_ai_marker.js` or leave out, chips of what "@" names), and over that what the task needs or
does now (`ui_ai_now.js`: a gate to allow, a run's progress with stop, the AI's open question
+5 -3
View File
@@ -11,7 +11,7 @@
### 右边一栏一次一样,按钮固定在右上角 / One view at a time on the right, switched from the top right
- 工作台右边那一栏一次只显示一样东西,用窗口右上角的三个按钮切换:**展台**、**节点结构**(AI 跑过的节点结构图,大图,可以选看哪一次运行)、**素材**(原来停在屏幕右边的素材小窗,现在就在这一栏里)。再点一下亮着的按钮收起这一栏;切走再切回来,内容都还在。时间线里每次运行都有 **在右侧看大图**。
- 任务标题和那一排按钮在时间线往下翻时一直停在顶上。
- 右边这一栏改成一张单独的卡片,不再用一条线硬隔开;默认窄一些,给中间的时间线留出地方。鼠标移到它左边缘会出现小竖条,拖动调宽窄(记得住,双击恢复)。
- 右边这一栏改成一张单独的卡片,不再用一条线硬隔开;默认窄一些,给中间的时间线留出地方。它左边的缝里有三个小点,鼠标移上去变成一条竖线,拖动调宽窄(记得住,双击恢复)。这条缝和时间线的滚动条分开,不会再一抓就抓错。
- 左边的任务列表用窗口左上角的按钮收起 / 展开;收起后鼠标移到按钮上,列表会临时浮出来,点任务或移开就收回。
- 素材在右边这一栏里时,鼠标移过卡片不再自动跳进详细说明(之前很难操作),点卡片上的 ⓘ 才看。
- 左边任务列表收起后,鼠标从按钮往下移去点任务时列表不会再中途消失(之前移得慢一点就会收回):鼠标在按钮和列表围成的范围里都算还在用,离开后才收回。
@@ -48,8 +48,10 @@
- Pictures dropped on the message box or pasted are kept in the output folder's `ai_inbox` (seen in the gallery; never over a file; PNG, JPEG, WebP or GIF up to 25 MB) and attached; an output picture dragged from ComfyUI's page is attached as it is.
- **很长的 AI 留言先折叠**:超过八行左右的留言只显示开头,点 **展开全文** 看全部。
- Long AI notes are folded to their first lines with "Show all".
- **输入框固定在最下面,像聊天软件一样**:不管时间线多长、翻到哪里,留言框都停在任务这一栏的最下方。它上面是这条消息要带的东西:图片显示成小图,每张有 **✎ 标注**(画框、圈、写要改哪里,标好后单独发给 AI)和 **×**;@ 提到的模型、搭配显示成小标签。从图库挑的图不再往文字里塞“@文件名”。左边是 **图库** 和 **素材** 两个按钮,给了东西就亮起并显示数量。输入框会随内容变高:Enter 发送,Shift+Enter 换行;只发图片不写字也可以。往上翻着看时 AI 有了新步骤,输入框上方会出现 **下面有新的步骤**,点一下回到最新;自己发出的消息会自动滚到眼前。时间线里你发的图片和 AI 出的图一样,可以查看、对比、收藏、标注。
- The message box is docked at the foot of the task column like a chat app's: over the input what the message carries (pictures with ✎ to mark up and ×, chips for what "@" names; gallery pictures are attached, not typed in), beside it the gallery and materials buttons with their counts, an input that grows (Enter sends, Shift+Enter breaks the line; pictures alone are fine), "New steps below" when the AI adds steps while you read further up, and your own message scrolled into view. Pictures you sent carry the same tools as the AI's.
- **输入框固定在最下面,像聊天软件一样**:不管时间线多长、翻到哪里,留言框都停在任务这一栏的最下方。它上面是这条消息要带的东西:图片显示成小图,每张有 **✎ 标注**(画框、圈、写要改哪里,标好后单独发给 AI)和 **×**;@ 提到的模型、搭配显示成小标签。从图库挑的图不再往文字里塞“@文件名”。左边是 **图库** 和 **素材** 两个按钮,给了东西就亮起并显示数量。输入框会随内容变高:Enter 发送,Shift+Enter 换行;只发图片不写字也可以。往上翻了一屏以上,或者往上翻着看时 AI 有了新步骤,输入框右上方会出现圆形 **↓** 按钮(有新步骤时角上带数字),点一下回到最新;自己发出的消息会自动滚到眼前。时间线里你发的图片和 AI 出的图一样,可以查看、对比、收藏、标注。
- The message box is docked at the foot of the task column like a chat app's: over the input what the message carries (pictures with ✎ to mark up and ×, chips for what "@" names; gallery pictures are attached, not typed in), beside it the gallery and materials buttons with their counts, an input that grows (Enter sends, Shift+Enter breaks the line; pictures alone are fine), a round ↓ button back to the newest step (with the count of new ones) when you read more than a screen up or the AI adds steps meanwhile, and your own message scrolled into view. Pictures you sent carry the same tools as the AI's. The side column's grip sits in its own gap, three dots, beside the timeline's scrollbar instead of over it.
- **打开任务就停在最新一步**:像聊天软件一样,打开任务直接看到最新的进展。你停在最下面时,AI 的新步骤来了就跟着往下走,不管任务现在是什么状态(之前只在 AI 正在干活时才跟,打开任务时还会停在上一个任务翻到的位置);往上翻着看就不动你。
- A task opens at its newest step; read at the foot, the timeline follows new steps whatever the task's state (it used to follow only while the AI worked, and opened where the previous task was scrolled to); read further up, it stays put.
- **点得到、看得见**:选中的图片有粗框、发光和大对勾,图片罩上一层颜色,和鼠标停留一眼分得开;留言框左边的图片按钮在图库面板打开时亮起,留言里有图时显示张数。素材列表、AI“看了资料”那一行(带 › 可展开)、关卡选项、优先用 / 只能用、来源下拉、发送和复制按钮、模型页的标签、首页的“一键准备”、记录里可展开的一行,鼠标移上去都有明显的变化。点 **展台** 时即使还没有内容,也会打开并说明会放什么,不再没反应。“交给 AI · 0”在什么都没选时不能点。挪模型那一步的标题写文件名和挪到哪,不再是一串编号;只改了一个节点时不再出现两个“在画布上找到”;“接着哪个任务…”的字号和旁边一致。
- Clearer feedback: picked pictures get a thick glowing frame, a tint and a big tick; the picture button lights while its panel is open and counts the message's pictures; materials rows, the folded reads row (now with a ›), gate options, the mode switch, the source menu, send and copy buttons, the models page's tabs, the home page's set-up button and expandable activity rows all react clearly to the pointer. The Stage button opens an empty stage that says what will appear there; "Hand to AI · 0" is disabled with nothing picked; move_model's step names the file and where it goes; a one-node canvas change shows one "find on canvas"; the "Goes on from…" chip matches the size around it.
- 右边那一栏的宽度把手更好抓了:卡片左边整条空隙(从上到下)都能拖,把手平时就淡淡地显示,鼠标靠近时整条边亮起;点一下后也能用 ← → 调宽窄。
+4 -2
View File
@@ -1664,7 +1664,8 @@ export const i18n = {
aiTrayRemove: "不发这张",
aiTrayMark: "标注",
aiTrayMarkHint: "在图上画框、圈、箭头,写要改哪里;标好后单独发给 AI",
aiNewSteps: "下面有新的步骤",
aiNewSteps: "{count} 个新步骤,点一下看最新",
aiToNewest: "回到最新一步",
aiMessageHint: "AI 做下一步或来读取时就会收到。它要是已经停下了,请回到 AI 软件里说。",
aiMessageEnded: "这个任务已经结束了,AI 多半不会再来看;新的要求请回到 AI 软件里说。",
aiMarkZoom: "放大看这里(再点一下还原)",
@@ -4975,7 +4976,8 @@ export const i18n = {
aiTrayRemove: "Leave this one out",
aiTrayMark: "Mark up",
aiTrayMarkHint: "Draw boxes, circles and arrows on it and say what to change; it goes to the AI on its own",
aiNewSteps: "New steps below",
aiNewSteps: "{count} new {count|step|steps}: click for the newest",
aiToNewest: "Back to the newest step",
aiMessageHint: "The AI gets it on its next step or when it asks. If it has stopped, tell it in the AI app.",
aiMessageEnded: "This task has ended, so the AI will probably not look here again; ask for more in the AI app.",
aiMarkZoom: "Zoom in here (again: back)",
+2 -25
View File
@@ -8,20 +8,18 @@
* (ui_ai_mention.js). The words and what is attached
* go to `onSend(text, refs)`, words optional when something is attached; the AI gets them the
* next time it asks (get_answers), which the hint says, and says to ask in the AI app once the
* task ended. Made once per task, so half-typed text survives the task's redraws. newStepsPill
* is the "new steps below" button over it.
* task ended. Made once per task, so half-typed text survives the task's redraws.
*/
import { translate as t } from './locales.js';
import { mentions } from './ui_ai_mention.js';
import { galleryPicker } from './ui_ai_gallery_pick.js';
import { takePictures } from './ui_ai_drop.js';
import { icon, iconButton } from './ui_ai_icons.js';
import { iconButton } from './ui_ai_icons.js';
const MAX_REFS = 12; // api/ai_message_refs.py
const MAX_PICTURES = 8; // a message's pictures, as ai_pictures.py hands them
const MAX_HEIGHT = 168; // the input grows up to about seven lines, then scrolls
const NEAR_BOTTOM = 60;
function el(tag, className, text) {
const node = document.createElement(tag);
@@ -128,24 +126,3 @@ export function updateMessageBox(box, ended, status, materials = 0) {
box.field.title = box.note.textContent;
box.setMaterials?.(materials);
}
/**
* "New steps below" over the message box, for a timeline in `scroller`: shown by `show()` when
* steps came in while the user read further up, gone once they are at the bottom; a click takes
* them there.
*/
export function newStepsPill(scroller) {
const pill = el('button', 'anomalous-ai-new-steps');
pill.type = 'button';
pill.hidden = true;
pill.append(icon('chevronDown', 14), el('span', '', t('aiNewSteps')));
pill.onclick = () => scroller.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' });
const atBottom = () => scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < NEAR_BOTTOM;
const seen = () => {
if (!pill.isConnected) scroller.removeEventListener('scroll', seen); // another task's view by now
else if (atBottom()) pill.hidden = true;
};
scroller.addEventListener('scroll', seen, { passive: true });
pill.show = () => { if (!atBottom()) pill.hidden = false; };
return pill;
}
+46
View File
@@ -0,0 +1,46 @@
/**
* The way back to a task's newest step, over the message box (ui_ai_workbench.js puts it there): a
* round button, there while the user reads more than a screen above the timeline's foot or the AI's
* steps came in since they left it, with how many on its corner; a click takes them down. atFoot
* tells the workbench whether a redraw keeps the timeline at its foot.
*/
import { translate as t } from './locales.js';
import { icon } from './ui_ai_icons.js';
const NEAR_BOTTOM = 60;
const below = scroller => scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
/** Whether `scroller` is scrolled to (about) its foot. */
export const atFoot = scroller => below(scroller) < NEAR_BOTTOM;
/** The button for the timeline in `scroller`; `arrived(count)` after a redraw that brought the AI's new steps. */
export function newestButton(scroller) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'anomalous-ai-newest';
button.hidden = true;
const count = document.createElement('span');
count.className = 'anomalous-ai-newest-count';
button.append(icon('chevronDown', 18), count);
let unseen = 0;
const draw = () => {
if (atFoot(scroller)) unseen = 0;
button.hidden = !unseen && below(scroller) < scroller.clientHeight;
count.textContent = unseen > 99 ? '99+' : unseen ? String(unseen) : '';
button.title = unseen ? t('aiNewSteps', { count: unseen }) : t('aiToNewest');
button.setAttribute('aria-label', button.title);
};
button.onclick = () => scroller.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' });
const scrolled = () => {
if (!button.isConnected) scroller.removeEventListener('scroll', scrolled); // another task's view by now
else draw();
};
scroller.addEventListener('scroll', scrolled, { passive: true });
button.arrived = (steps) => {
if (!atFoot(scroller)) unseen += steps;
draw();
};
return button;
}
+12 -7
View File
@@ -13,6 +13,8 @@
* 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.
@@ -26,7 +28,8 @@ import { translate as t } from './locales.js';
import { dayLabel, fetchActivity, timeLabel } from './activity_log.js';
import { canUndo, undoEntry } from './canvas_undo.js';
import { anomalousAlert, anomalousConfirm, anomalousPrompt } from './ui_dialog.js';
import { messageBox, newStepsPill, updateMessageBox } from './ui_ai_message.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';
@@ -206,14 +209,15 @@ function renderTask() {
state.owner?.headerTools?.replaceChildren();
return;
}
const keepBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 40;
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;
if (!view || view.taskId !== task.id || !box.contains(view.timeline)) {
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'),
@@ -226,10 +230,10 @@ function renderTask() {
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, pill: newStepsPill(box), now: nowStrip(), dock: el('div', 'anomalous-ai-dock'),
seen: (task.steps || []).length, newest: newestButton(box), now: nowStrip(), dock: el('div', 'anomalous-ai-dock'),
};
view.tabs = el('div', 'anomalous-ai-tabs');
view.dock.append(view.pill, view.now, view.message); // at the foot of the column, in view while the timeline scrolls
view.dock.append(view.newest, view.now, view.message); // at the foot of the column, in view while the timeline scrolls
box.replaceChildren(view.tabs, view.header, view.flow, view.made, view.timeline, view.dock);
}
// A new panel that asks something brings the stage forward when the window is narrow.
@@ -304,8 +308,9 @@ function renderTask() {
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);
box.scrollTop = keepBottom && status === 'live' ? box.scrollHeight : keepTop;
if ((task.steps || []).slice(view.seen).some(step => step.kind !== 'user') && !keepBottom) view.pill.show(); // the AI's, while the user read further up
// Opened, at its newest step (a page being prepared, at its top); read at the foot, kept there.
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);
}
+3 -3
View File
@@ -38,11 +38,11 @@
@import url("./styles/37-ai-workbench.css?v=20261024-short");
@import url("./styles/38-ai-panels.css?v=20261024-feel");
@import url("./styles/39-ai-stage.css?v=20261021-stage");
@import url("./styles/40-ai-side.css?v=20261024-dock");
@import url("./styles/40-ai-side.css?v=20261024-follow");
@import url("./styles/41-ai-results.css?v=20261024-dock");
@import url("./styles/42-ai-flow.css?v=20261024-feel");
@import url("./styles/43-ai-toolbox.css?v=20261024-feel");
@import url("./styles/44-ai-status.css?v=20261024-stop");
@import url("./styles/45-ai-side-views.css?v=20261024-grip");
@import url("./styles/46-ai-mention.css?v=20261024-now");
@import url("./styles/45-ai-side-views.css?v=20261024-follow");
@import url("./styles/46-ai-mention.css?v=20261024-follow");
@import url("./styles/47-ai-decisions.css?v=20261024-feel");
+1 -1
View File
@@ -90,7 +90,7 @@
flex-direction: column;
min-width: 0;
min-height: 0;
margin: 10px 10px 10px 0;
margin: 10px 10px 10px 14px; /* the gap on the left is the grip's (45-ai-side-views.css) */
padding: 16px 18px 18px;
border: 1px solid var(--amb-border);
border-radius: 14px;
+15 -16
View File
@@ -113,16 +113,16 @@
}
#anomalous-header .anomalous-ai-side-btn svg { width: 15px !important; height: 15px !important; }
/* The side column's grip: the whole gap left of the card, top to bottom (easy to catch), with a
handle always faintly there and a full-height line while it is pointed at or dragged; dragging
sets --ai-side-w. Focused, the arrow keys resize too. */
/* The side column's grip: the gap left of the card, top to bottom, beside the timeline's scrollbar
(never over it), with three dots always faintly there and a full-height line instead while it is
pointed at or dragged; dragging sets --ai-side-w. Focused, the arrow keys resize too. */
.anomalous-ai-side-grip {
position: absolute;
z-index: 3;
top: 0;
bottom: 0;
left: -22px;
width: 22px;
left: -14px;
width: 14px;
cursor: col-resize;
touch-action: none;
}
@@ -131,7 +131,7 @@
position: absolute;
top: 8px;
bottom: 8px;
left: 10px;
left: 6px;
width: 2px;
border-radius: 1px;
background: var(--amb-link);
@@ -142,17 +142,16 @@
content: '';
position: absolute;
top: 50%;
left: 8px;
width: 6px;
height: 56px;
margin-top: -28px;
border-radius: 3px;
background: var(--amb-border-strong, var(--amb-border));
opacity: 0.55;
transition: opacity 0.15s, background 0.15s;
left: 5px;
width: 4px;
height: 24px;
margin-top: -12px;
background: radial-gradient(circle, var(--amb-text-dim) 1.5px, transparent 2px) 0 0 / 4px 8px repeat-y;
opacity: 0.8;
transition: opacity 0.15s;
}
.anomalous-ai-side-grip:hover::before, .anomalous-ai-side-grip.is-dragging::before, .anomalous-ai-side-grip:focus-visible::before { opacity: 0.6; }
.anomalous-ai-side-grip:hover::after, .anomalous-ai-side-grip.is-dragging::after, .anomalous-ai-side-grip:focus-visible::after { background: var(--amb-link); opacity: 1; }
.anomalous-ai-side-grip:hover::before, .anomalous-ai-side-grip.is-dragging::before, .anomalous-ai-side-grip:focus-visible::before { opacity: 0.8; }
.anomalous-ai-side-grip:hover::after, .anomalous-ai-side-grip.is-dragging::after, .anomalous-ai-side-grip:focus-visible::after { opacity: 0; }
.anomalous-ai-side-grip:focus-visible { outline: none; }
.anomalous-ai.is-tabbed .anomalous-ai-side-grip { display: none; }
body.anomalous-ai-resizing, body.anomalous-ai-resizing * { cursor: col-resize !important; user-select: none !important; }
+32 -15
View File
@@ -222,28 +222,45 @@
.anomalous-ai-pics-foot { display: flex; flex: none; align-items: center; gap: 10px; }
.anomalous-ai-pics-count { flex: 1; min-width: 0; color: var(--amb-text-dim); font-size: 12px; }
/* "New steps below" over the message box (ui_ai_message.js newStepsPill). */
.anomalous-ai-new-steps {
/* The way back to the newest step, at the right over the message box (ui_ai_newest.js): how many
new steps came in on its corner. */
.anomalous-ai-newest {
position: absolute;
z-index: 4;
bottom: calc(100% + 2px);
left: 50%;
display: inline-flex;
right: 6px;
bottom: calc(100% - 4px);
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: 1px solid color-mix(in srgb, var(--amb-link) 60%, transparent);
border-radius: 999px;
justify-content: center;
width: 38px;
height: 38px;
padding: 0;
border: 1px solid var(--amb-border-strong, var(--amb-border));
border-radius: 50%;
background: var(--amb-bg-panel);
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.4);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
color: var(--amb-text-main);
font: inherit;
font-size: 12.5px;
cursor: pointer;
transform: translateX(-50%);
}
.anomalous-ai-new-steps[hidden] { display: none; }
.anomalous-ai-new-steps:hover { background: color-mix(in srgb, var(--amb-link) 22%, var(--amb-bg-panel)); }
.anomalous-ai-newest[hidden] { display: none; }
.anomalous-ai-newest:hover, .anomalous-ai-newest:focus-visible { border-color: var(--amb-link); background: color-mix(in srgb, var(--amb-link) 22%, var(--amb-bg-panel)); }
.anomalous-ai-newest-count {
position: absolute;
top: -6px;
right: -6px;
min-width: 18px;
height: 18px;
padding: 0 5px;
box-sizing: border-box;
border-radius: 9px;
background: var(--amb-link);
color: #fff;
font-size: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 18px;
}
.anomalous-ai-newest-count:empty { display: none; }
/* What the task needs or does now, over the message box (ui_ai_now.js). */
.anomalous-ai-now { display: flex; flex-direction: column; gap: 8px; margin-bottom: 8px; }