diff --git a/docs/UI_CONTRACT.md b/docs/UI_CONTRACT.md
index 7acaf490d..b8f529d83 100644
--- a/docs/UI_CONTRACT.md
+++ b/docs/UI_CONTRACT.md
@@ -64,7 +64,7 @@ This contract preserves the approved MVP concept and prevents feature ownership
- The Files viewer renders Markdown (`.md`, `.markdown`, `.mdown`, `.mkd`) as formatted GitHub-Flavored Markdown with fenced code blocks syntax-highlighted, and syntax-highlights recognized code files by the language detected from their extension while unknown languages stay plain monospaced text. All rendered output is sanitized: raw HTML and scripts never render and event-handler attributes and dangerous link schemes such as `javascript:` are neutralized. Activating an `http(s)` link opens it in the built-in browser while other schemes are inert, only embedded `data:` images render while remote or relative sources fall back to alt text or a placeholder without a network request, and text or code above the rich-rendering size threshold falls back to plain text with a notice.
- An authenticated connection or heartbeat alone never shows agent presence. A branded badge appears only after that agent actually issues a browser command, and its cursor appears only after a real pointer position exists. Claude uses `#D97757`, Codex `#10A37F`, Qwen `#6D44E8`, Kimi `#7C5CFC`, OpenCode `#5A5858`, Hermes `#D6A700`, and an unknown provider `#7A8291`. Browser Settings separately controls indicator visibility without revoking browser access; the agent-access kill switch revokes the connection itself.
- With window snapping enabled, drag and resize use a hidden `10px` grid and a `10px` magnetic threshold for neighboring edges, centers, and a consistent `20px` gap.
-- One trusted context dispatcher owns right-clicks on the canvas. Truly empty canvas offers region creation, a sticky note, the Settings-configured launcher submenu, Browser, and Settings. Holding RMB on empty canvas also opens the configurable radial quick launcher: moving toward an action and releasing activates it, while a stationary right-click continues to open the standard empty-canvas menu. Region and note right-clicks remain context-specific. A region offers rename, color, a note at the pressed point, and region-only deletion. A note frame offers edit, bring-to-front, and deletion. Terminal, Browser, plugin, textarea/input, and contenteditable surfaces keep their native context menus; middle-button panning is unchanged. `Cmd/Ctrl+K` opens a searchable palette over live sessions and the same global creation/launch actions. Regions persist independently of terminal restore, render behind HOME and windows, resize from every edge/corner, and move every terminal, plugin, Browser, or note whose complete bounds were inside the region at drag start. Attached contents follow the region continuously during the gesture and persist once at pointer release; deleting a region never deletes its contents. Sticky notes persist bounded text and geometry, snap like other windows, and resize from every edge/corner. General Settings exposes independent **Save colored regions after exit** and **Save notes after exit** controls. Both default on to preserve existing profiles. Turning either off removes only that collection from disk persistence without deleting its live canvas objects; turning it back on saves the current collection, while leaving it off starts the next launch with that collection empty.
+- One trusted context dispatcher owns right-clicks on the canvas. Truly empty canvas offers region creation, a sticky note, the Settings-configured launcher submenu, Browser, Files, a **Tools** submenu, and Settings. **Tools** holds a single **Auto-sort** action whose criteria (by task, i.e. subagents to the right of the session that spawned them; by status; by project folder; grid) are radio rows in that submenu; there is no separate layouts surface, toolbar, or saved preset. Auto-sort moves the windows of the current multi-selection when it holds at least two, or else every terminal, plugin, and Browser window that is not completely inside a region (companion mascots move only when selected); sticky notes, materials, and Files cards never move. The sorted block keeps the snapping grid and `20px` gap when snapping is on, and shifts right or down until it is clear of HOME, regions, notes, materials, Files cards, and windows it does not move. The moved windows show the existing selection outline for a few seconds or until the next press; it is presentation-only and does not select them. They glide into place unless reduced motion is requested, and **Undo auto-sort** stays in the same submenu while any of them is still where the sort put it. Undo restores only those untouched windows; there is no global undo shortcut, so terminal and text inputs keep their keys. The criterion is remembered only for the running session. Holding RMB on empty canvas also opens the configurable radial quick launcher: moving toward an action and releasing activates it, while a stationary right-click continues to open the standard empty-canvas menu. Region and note right-clicks remain context-specific. A region offers rename, color, a note at the pressed point, and region-only deletion. A note frame offers edit, bring-to-front, and deletion. Terminal, Browser, plugin, textarea/input, and contenteditable surfaces keep their native context menus; middle-button panning is unchanged. `Cmd/Ctrl+K` opens a searchable palette over live sessions and the same global creation/launch actions. Regions persist independently of terminal restore, render behind HOME and windows, resize from every edge/corner, and move every terminal, plugin, Browser, or note whose complete bounds were inside the region at drag start. Attached contents follow the region continuously during the gesture and persist once at pointer release; deleting a region never deletes its contents. Sticky notes persist bounded text and geometry, snap like other windows, and resize from every edge/corner. General Settings exposes independent **Save colored regions after exit** and **Save notes after exit** controls. Both default on to preserve existing profiles. Turning either off removes only that collection from disk persistence without deleting its live canvas objects; turning it back on saves the current collection, while leaving it off starts the next launch with that collection empty.
- Context menus, region editors, the radial launcher, and the command palette use official Lucide/provider assets and shared application color tokens. Their dimensions follow root `--ui-scale`. Appearance owns the `0.85–1.25` scale in `0.05` steps and the sage/lilac/night system palette; the scale changes UI chrome rather than canvas zoom or terminal font size. Agents owns the independent context-menu and radial-launcher subsets and their safe reset defaults.
- Settings uses the approved two-pane modal grammar: a persistent icon-labelled sidebar for all seven sections, a separate current-section header, and a scrollable content pane. Settings must not fall back to the legacy light slide-over with one horizontal segmented tab strip. Simple settings use compact copy/control rows; all Settings chrome reads the active system tokens, official Lucide assets, and root `--ui-scale`.
- General Settings places the macOS/Windows/Linux/Custom keyboard preset after language, followed by an expandable Keyboard shortcuts section for canvas, terminal, Codex editor and canvas navigation bindings. Recording a shortcut selects Custom; choosing a named preset replaces bindings. Optional commands can be disabled, while submit, newline and whole-draft selection remain bound. Canvas and terminal bindings update immediately; native Codex editor bindings apply to new or restarted cards. Focused terminal and text inputs retain ownership of canvas keyboard shortcuts on every platform.
diff --git a/docs/UI_CONTRACT.ru.md b/docs/UI_CONTRACT.ru.md
index 33926a2da..97bdcf51b 100644
--- a/docs/UI_CONTRACT.ru.md
+++ b/docs/UI_CONTRACT.ru.md
@@ -59,6 +59,7 @@
- Мини-карта центрирует камеру по нажатию без изменения масштаба. В Controls выбирается **Клик** (по умолчанию, движение указателя игнорируется) или **Перетаскивание** (мини-карта захватывает указатель и непрерывно двигает камеру); клавиши-стрелки работают в обоих режимах.
- Canvas двигается RTS-style, пока указатель находится в пределах `56px` от viewport edge над пустым canvas; скорость линейно возрастает до `900px/s` на самой границе. Edge panning выключен по умолчанию и включается в Settings. Motion останавливается над interactive surfaces и во время drag-pan.
- Dialog close actions остаются внутри своей header/control row с одинаковым inset и не перекрывают field, outline или panel boundary.
+- Контекстное меню пустого канваса содержит подменю **Инструменты** с одним действием **Автосортировка**. Критерии (по задачам — субагенты справа от запустившей их сессии; по статусу; по папке проекта; сеткой) — пункты-переключатели в том же подменю, без отдельной панели, тулбара или сохранённых раскладок. Сортируются окна текущего множественного выделения, если их хотя бы два, а иначе — все окна терминалов, плагинов и браузера, которые не лежат целиком внутри региона (маскоты — только если выделены); заметки, материалы и карточки Files не двигаются. При включённой привязке блок держит сетку и зазор `20px`, и сдвигается вправо или вниз, пока не перестанет задевать HOME, регионы, заметки, материалы, карточки Files и неперемещаемые окна. Перемещённые окна на несколько секунд (или до следующего нажатия) получают обычную обводку выделения — только визуально, без выделения — и плавно переезжают, если не включено уменьшение движения. Пункт **Отменить автосортировку** остаётся в подменю, пока хотя бы одно окно стоит там, куда его поставила сортировка, и возвращает только нетронутые окна. Глобального сочетания отмены нет: клавиши остаются у терминалов и полей ввода. Выбранный критерий помнится только до перезапуска.
## Приёмочные проверки
diff --git a/docs/UI_CONTRACT.zh-CN.md b/docs/UI_CONTRACT.zh-CN.md
index 9baea3f06..374fae9fb 100644
--- a/docs/UI_CONTRACT.zh-CN.md
+++ b/docs/UI_CONTRACT.zh-CN.md
@@ -58,6 +58,7 @@
- 在空白 canvas 上 drag 始终执行 pan。新配置中,空白 canvas 上的普通 scroll 会沿两个轴移动,pinch 与 `Cmd/Ctrl + scroll` 会以指针为中心缩放。**Use scroll wheel to zoom** 恢复历史 wheel zoom,并在迁移现有配置时自动启用。只有 focused input widget 会中断 canvas wheel/pinch;在 unfocused 或不可聚焦 widget 上,事件仍归 canvas。**Wheel/pinch capture over widgets** 有 Off、On、Key 三种模式:Off 保留 focused-widget ownership,On 始终只交给 canvas,Key 仅在按住绑定键时交给 canvas。因此,focused live Browser page 在 Off 或 Key 未按下时会由普通 scroll 原生滚动;unfocused Browser、On、激活的 Key binding、完整 override、pinch 与 `Cmd/Ctrl + scroll` 都归 canvas。Browser wheel sequence 开始时选定的 owner 会保持到 250 ms 没有 wheel event。Browser summary 与 placeholder 始终归 canvas。新配置默认使用 Key,在 macOS 为 `Command`,其他平台为 `Ctrl`。独立的 **Canvas navigation override**(macOS 默认 `Option`,其他平台默认 `Alt`)接管 wheel/pinch 与 drag,也允许单独 Command/Ctrl。按住完整 override 时所有 canvas surface 显示 `grab`;已开始的 pan 会显示 `grabbing`,并由 canvas 持有到 pointer up/cancel。
- 指针停在空白 canvas 的 viewport edge `56px` 范围内时,canvas 以 RTS 风格 edge-pan;速度线性增加,到边缘达到 `900px/s`。Edge panning 默认关闭,在 Settings 启用。指针位于 interactive surface 或正在 drag-pan 时暂停。
- Dialog close action 留在自己的 header/control row 内,使用一致 inset,不覆盖 field、outline 或 panel boundary。
+- 空白画布的右键菜单包含 **Tools** 子菜单,其中只有一个 **Auto-sort** 操作。排序条件(按任务:子代理放在启动它的会话右侧;按状态;按项目文件夹;网格)是同一子菜单中的单选行,不提供单独的面板、工具栏或保存的布局。当前多选中至少有两个窗口时,Auto-sort 只移动这些窗口;否则,移动所有未完全位于区域内的终端、插件与 Browser 窗口(吉祥物仅在被选中时移动);便签、素材与 Files 卡片从不移动。开启吸附时排列保持 10px 网格与 `20px` 间距,并向右或向下移动,直到不再覆盖 HOME、区域、便签、素材、Files 卡片和未参与排序的窗口。被移动的窗口在几秒内(或直到下一次按下)显示现有的选中轮廓,这只是视觉提示,不会选中它们;在未要求减少动态效果时平滑移动。只要仍有窗口停在排序放置的位置,**Undo auto-sort** 就保留在同一子菜单中,并且只恢复这些未被改动的窗口。没有全局撤销快捷键,终端与文本输入保留自己的按键。所选条件只在本次运行期间记住。
## 验收检查
diff --git a/src/renderer/src/assets/icons/lucide/README.md b/src/renderer/src/assets/icons/lucide/README.md
index ba3bcd5c6..cddf42626 100644
--- a/src/renderer/src/assets/icons/lucide/README.md
+++ b/src/renderer/src/assets/icons/lucide/README.md
@@ -3,7 +3,8 @@
These SVG files are unmodified icons downloaded from the official
[`lucide-icons/lucide`](https://github.com/lucide-icons/lucide) repository on
2026-08-04, with menu and Settings navigation additions fetched from the same
-source on 2026-08-30 and canvas material additions on 2026-09-28.
+source on 2026-08-30 and canvas material additions on 2026-09-28, and the canvas
+Tools menu additions (`wrench`, `undo-2`) on 2026-10-12.
They are vendored as local assets so CanvasTTY does not need to
execute or fetch an icon package from npm.
diff --git a/src/renderer/src/assets/icons/lucide/undo-2.svg b/src/renderer/src/assets/icons/lucide/undo-2.svg
new file mode 100644
index 000000000..3e93594c0
--- /dev/null
+++ b/src/renderer/src/assets/icons/lucide/undo-2.svg
@@ -0,0 +1,14 @@
+
diff --git a/src/renderer/src/assets/icons/lucide/wrench.svg b/src/renderer/src/assets/icons/lucide/wrench.svg
new file mode 100644
index 000000000..362434321
--- /dev/null
+++ b/src/renderer/src/assets/icons/lucide/wrench.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/renderer/src/components/CanvasMenuPrimitives.tsx b/src/renderer/src/components/CanvasMenuPrimitives.tsx
index 1347093d0..73f4ea983 100644
--- a/src/renderer/src/components/CanvasMenuPrimitives.tsx
+++ b/src/renderer/src/components/CanvasMenuPrimitives.tsx
@@ -66,9 +66,10 @@ export function CanvasMenuSub({ children }: { children: ReactNode }): React.JSX.
return {children};
}
-export function CanvasMenuChevron(): React.JSX.Element {
+/** `side`: the submenu opens to that side, and the chevron points there; without it, it points down. */
+export function CanvasMenuChevron({ side }: { side?: "left" | "right" } = {}): React.JSX.Element {
return (
-
+
);
diff --git a/src/renderer/src/components/UiIcon.tsx b/src/renderer/src/components/UiIcon.tsx
index 27438ad18..d60f1f781 100644
--- a/src/renderer/src/components/UiIcon.tsx
+++ b/src/renderer/src/components/UiIcon.tsx
@@ -38,7 +38,9 @@ import slidersHorizontalIcon from "../assets/icons/lucide/sliders-horizontal.svg
import stickyNoteIcon from "../assets/icons/lucide/sticky-note.svg";
import terminalIcon from "../assets/icons/lucide/square-terminal.svg";
import trashIcon from "../assets/icons/lucide/trash-2.svg";
+import undoIcon from "../assets/icons/lucide/undo-2.svg";
import workingIcon from "../assets/icons/lucide/loader-circle.svg";
+import wrenchIcon from "../assets/icons/lucide/wrench.svg";
import zoomInIcon from "../assets/icons/lucide/zoom-in.svg";
import zoomOutIcon from "../assets/icons/lucide/zoom-out.svg";
@@ -84,6 +86,8 @@ export type UiIconName =
| "clipboard-paste"
| "ellipsis"
| "trash"
+ | "undo"
+ | "wrench"
| "working"
| "attention"
| "error"
@@ -137,6 +141,8 @@ const ICONS: Record = {
"clipboard-paste": clipboardPasteIcon,
ellipsis: ellipsisIcon,
trash: trashIcon,
+ undo: undoIcon,
+ wrench: wrenchIcon,
working: workingIcon,
attention: attentionIcon,
error: errorIcon,
diff --git a/src/renderer/src/features/workspace/CanvasAutoSortMenu.tsx b/src/renderer/src/features/workspace/CanvasAutoSortMenu.tsx
new file mode 100644
index 000000000..a9234988b
--- /dev/null
+++ b/src/renderer/src/features/workspace/CanvasAutoSortMenu.tsx
@@ -0,0 +1,108 @@
+import type { LocaleId } from "../../../../shared/contracts";
+import { CanvasMenuDivider, CanvasMenuLabel, CanvasMenuRow } from "../../components/CanvasMenuPrimitives";
+import { UiIcon } from "../../components/UiIcon";
+import { t } from "../../lib/i18n";
+import { planCanvasAutoSort } from "./canvasAutoSort";
+import {
+ CANVAS_AUTO_SORT_MODES,
+ canvasAutoSortScope,
+ canvasAutoSortUndoTargets,
+ type CanvasAutoSortCandidate,
+ type CanvasAutoSortHost,
+ type CanvasAutoSortMode
+} from "./canvasAutoSortScope";
+import { parseCanvasLayerId, pluginLayerId, terminalLayerId } from "./canvasSelectionGesture";
+
+/**
+ * The body of the empty-canvas Tools submenu. The context menu loads it, with the layout code,
+ * the first time Tools opens, so none of it is in the startup bundle.
+ */
+const LABELS = {
+ task: "canvasAutoSortTask",
+ status: "canvasAutoSortStatus",
+ project: "canvasAutoSortProject",
+ grid: "canvasAutoSortGrid"
+} as const satisfies Record[1]>;
+
+/** The windows an auto-sort would move right now (see canvasAutoSortScope). */
+export function canvasAutoSortIds(host: CanvasAutoSortHost): string[] {
+ const mascots = new Set(host.pluginCanvas
+ .filter((instance) => host.plugins.find((plugin) => plugin.manifest.id === instance.pluginId)?.sourceUrl.startsWith("mascot:"))
+ .map((instance) => pluginLayerId(instance.id)));
+ const candidates: CanvasAutoSortCandidate[] = [];
+ for (const [id, bounds] of host.bounds) {
+ const kind = parseCanvasLayerId(id)?.kind;
+ if (kind) candidates.push({ id, kind, bounds, decorative: mascots.has(id) });
+ }
+ return canvasAutoSortScope(candidates, host.selection, host.regions);
+}
+
+/** Sort by `mode` and remember the step for undo; the criterion is kept only once a sort happened. */
+export function runCanvasAutoSort(host: CanvasAutoSortHost, mode: CanvasAutoSortMode): void {
+ const plan = planCanvasAutoSort(canvasAutoSortIds(host), host.bounds, host.sessions, terminalLayerId, mode, {
+ avoid: [host.home, ...host.regions],
+ snapToGrid: host.snapToGrid
+ });
+ if (!plan) return;
+ host.commit(plan.after);
+ host.setState({ mode, undo: { mode, ...plan } });
+ host.mark(new Set(plan.after.keys()));
+}
+
+/** Put back the windows that are still where the last sort placed them. */
+export function undoCanvasAutoSort(host: CanvasAutoSortHost): void {
+ if (!host.state.undo) return;
+ const restore = canvasAutoSortUndoTargets(host.state.undo, host.bounds);
+ host.setState({ mode: host.state.mode, undo: null });
+ if (restore.size === 0) return;
+ host.commit(restore);
+ host.mark(new Set(restore.keys()));
+}
+
+export function CanvasAutoSortMenu({ host, locale, className, onDone }: {
+ host: CanvasAutoSortHost;
+ locale: LocaleId;
+ className: string;
+ onDone(): void;
+}): React.JSX.Element {
+ const available = canvasAutoSortIds(host).length >= 2;
+ const undoAvailable = host.state.undo !== null && canvasAutoSortUndoTargets(host.state.undo, host.bounds).size > 0;
+ const run = (action: () => void): void => {
+ try {
+ action();
+ } catch (error) {
+ console.error("Auto-sort failed", error);
+ host.onError(t(locale, "canvasAutoSortFailed"));
+ }
+ onDone();
+ };
+ return (
+