From 877c1f99302ad58a7808d7e6ededc68b0d76dada Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:02:47 +0000 Subject: [PATCH 1/3] perf(canvas): keep the command palette and the context menu out of the startup bundle The canvas command palette and the canvas context menu are rendered only while they are open, but both were static imports of the canvas and so part of the startup bundle. They are now separate chunks, which frees about 18 KB of the startup bundle, and still open at once: the canvas preloads both chunks once it is idle after its first paint, and renders the loaded component directly. There is no lazy() and no Suspense boundary on this path (React holds a suspended boundary's reveal for up to 300 ms, which would delay the first right-click and the first Cmd/Ctrl+K). An open before the preload has finished loads the chunk at once and shows the menu as soon as it arrives. renderer-bundle-splitting checks that each is its own chunk, reachable only through a dynamic import(); canvas-preloaded-surfaces renders both with the real React through the real imports and checks that a preloaded menu and palette are in the very first render of an open. --- .../features/workspace/WorkspaceCanvas.tsx | 12 +- .../workspace/canvasOverlaySurfaces.ts | 6 + src/renderer/src/lib/preloadedSurface.ts | 63 +++++++++ tests/canvas-preloaded-surfaces.test.mjs | 126 ++++++++++++++++++ tests/renderer-bundle-splitting.test.mjs | 6 + 5 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/features/workspace/canvasOverlaySurfaces.ts create mode 100644 src/renderer/src/lib/preloadedSurface.ts create mode 100644 tests/canvas-preloaded-surfaces.test.mjs diff --git a/src/renderer/src/features/workspace/WorkspaceCanvas.tsx b/src/renderer/src/features/workspace/WorkspaceCanvas.tsx index 6fef8d6a..f1e298e3 100644 --- a/src/renderer/src/features/workspace/WorkspaceCanvas.tsx +++ b/src/renderer/src/features/workspace/WorkspaceCanvas.tsx @@ -54,8 +54,6 @@ import { TerminalCard } from "../terminal/TerminalCard"; import { shouldTogglePixelSkinMasterView } from "../terminal/terminalShortcuts"; import { isPixelSkinThemeId } from "../skins/skinCatalog"; import { isPixelSkinPackId, usePixelSkinPackAssets } from "../skins/SkinAssets"; -import { CanvasCommandPalette } from "./CanvasCommandPalette"; -import { CanvasContextMenu } from "./CanvasContextMenu"; import { CanvasMinimap } from "./CanvasMinimap"; import { AgentChatHistoryHud } from "./AgentChatHistoryHud"; import { CanvasRegionCard } from "./CanvasRegionCard"; @@ -115,6 +113,8 @@ import { useCanvasWheelNavigation } from "./useCanvasWheelNavigation"; import { useCanvasWidgetFocus } from "./useCanvasWidgetFocus"; import { useRemarkPopoverRect } from "./useRemarkPopoverRect"; import { webglContextPool } from "../terminal/webglContextPool"; +import { preloadWhenIdle, usePreloadedSurface } from "../../lib/preloadedSurface"; +import { canvasCommandPaletteSurface, canvasContextMenuSurface } from "./canvasOverlaySurfaces"; const CANVAS_OVERLAY_PLACEMENTS: readonly CanvasOverlayPlacement[] = [ "top-left", "top-right", @@ -307,6 +307,10 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element const [contextMenu, setContextMenu] = useState(null); const [regionEditor, setRegionEditor] = useState(null); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); + // Out of the startup bundle, preloaded once the canvas is idle, rendered without a Suspense boundary (see preloadedSurface). + const CanvasContextMenu = usePreloadedSurface(canvasContextMenuSurface, contextMenu !== null); + const CanvasCommandPalette = usePreloadedSurface(canvasCommandPaletteSurface, commandPaletteOpen); + useEffect(() => preloadWhenIdle([canvasContextMenuSurface, canvasCommandPaletteSurface]), []); const [radialLauncher, setRadialLauncher] = useState<{ anchor: Point; pointerAnchor: Point; @@ -1454,7 +1458,7 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element )} - {contextMenu && ( + {contextMenu && CanvasContextMenu && ( )} - {commandPaletteOpen && ( + {commandPaletteOpen && CanvasCommandPalette && ( import("./CanvasContextMenu").then((module) => module.CanvasContextMenu)); +export const canvasCommandPaletteSurface = preloadedSurface(() => import("./CanvasCommandPalette").then((module) => module.CanvasCommandPalette)); diff --git a/src/renderer/src/lib/preloadedSurface.ts b/src/renderer/src/lib/preloadedSurface.ts new file mode 100644 index 00000000..74d2a7fd --- /dev/null +++ b/src/renderer/src/lib/preloadedSurface.ts @@ -0,0 +1,63 @@ +import { useEffect, useState } from "react"; + +/** + * A surface (a menu, a palette) whose code is kept out of the startup bundle but still opens at once. Its chunk is + * loaded once, either ahead of time (`preloadWhenIdle`) or by the first open, and the loaded component is then + * rendered directly. It is not a `React.lazy` behind `Suspense`: React holds a suspended boundary's reveal for up to + * 300 ms, which delayed the first right-click and the first Cmd/Ctrl+K by that much. + */ +export interface PreloadedSurface { + /** Starts loading (once; a failed load is tried again next time) and settles with the loaded value. */ + load(): Promise; + /** The loaded value, or null while it has not loaded. */ + loaded(): T | null; +} + +export function preloadedSurface(load: () => Promise): PreloadedSurface { + let value: T | null = null; + let pending: Promise | null = null; + return { + load() { + pending ??= load().then((loaded) => { + value = loaded; + return loaded; + }, (error: unknown) => { + pending = null; + throw error; + }); + return pending; + }, + loaded: () => value + }; +} + +/** + * The surface's value for a render that shows it (`wanted`): the loaded one at once, or null while a load this open + * started is still running (the caller renders nothing until it arrives, then re-renders). + */ +export function usePreloadedSurface(surface: PreloadedSurface, wanted: boolean): T | null { + const [, setLoaded] = useState(0); + const value = surface.loaded(); + useEffect(() => { + if (!wanted || surface.loaded() !== null) return; + let live = true; + surface.load().then(() => { + if (live) setLoaded((count) => count + 1); + }, (error: unknown) => console.error("CanvasTTY could not load a menu", error)); + return () => { live = false; }; + }, [surface, wanted]); + return wanted ? value : null; +} + +/** Loads the surfaces once the app is idle after its first paint; returns a cancel for the pending start. */ +export function preloadWhenIdle(surfaces: readonly PreloadedSurface[]): () => void { + const start = (): void => { + for (const surface of surfaces) void surface.load().catch(() => undefined); + }; + if (typeof window.requestIdleCallback === "function") { + const handle = window.requestIdleCallback(start, { timeout: 2_000 }); + return () => window.cancelIdleCallback(handle); + } + const timer = window.setTimeout(start, 200); + return () => window.clearTimeout(timer); +} diff --git a/tests/canvas-preloaded-surfaces.test.mjs b/tests/canvas-preloaded-surfaces.test.mjs new file mode 100644 index 00000000..671b77f2 --- /dev/null +++ b/tests/canvas-preloaded-surfaces.test.mjs @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; + +// The context menu and the command palette are kept out of the startup bundle (renderer-bundle-splitting), but a +// lazy() behind made their first open wait about 300 ms (React holds a suspended boundary's reveal that +// long). They are preloaded once the canvas is idle and rendered directly: once loaded, the very first render of an +// open menu or palette already contains it. Rendered here with the real React and react-dom/server, through the real +// dynamic imports of canvasOverlaySurfaces. +const root = fileURLToPath(new URL("..", import.meta.url)); + +async function loadBundle(t) { + const outdir = await mkdtemp(join(tmpdir(), "canvastty-surfaces-")); + t.after(() => rm(outdir, { recursive: true, force: true })); + await build({ + stdin: { + contents: [ + 'export { canvasContextMenuSurface, canvasCommandPaletteSurface } from "./src/renderer/src/features/workspace/canvasOverlaySurfaces";', + 'export { preloadedSurface, preloadWhenIdle, usePreloadedSurface } from "./src/renderer/src/lib/preloadedSurface";', + 'export { createElement } from "react";', + 'export { renderToStaticMarkup } from "react-dom/server";' + ].join("\n"), + resolveDir: root, + loader: "ts" + }, + jsx: "automatic", + bundle: true, + platform: "node", + format: "cjs", + outfile: join(outdir, "surfaces.cjs"), + logLevel: "silent", + define: { "process.env.NODE_ENV": '"production"' }, + loader: { ".svg": "text", ".css": "empty", ".png": "empty", ".ttf": "empty", ".woff2": "empty", ".ico": "empty" } + }); + return createRequire(import.meta.url)(join(outdir, "surfaces.cjs")); +} + +const noop = () => undefined; +const menuProps = { + kind: "empty", position: { x: 40, y: 40 }, locale: "en", launcherItems: [], currentRegionColor: null, + materialHasLocation: false, onSendMaterial: null, onSendRemarks: null, + ...Object.fromEntries([ + "onCreateRegion", "onCreateNote", "onLaunch", "onOpenBrowser", "onOpenFiles", "onOpenSettings", "onRenameRegion", + "onChangeRegionColor", "onDeleteRegion", "onEditNote", "onBringNoteToFront", "onDeleteNote", "onAddFiles", + "onPasteFiles", "onPinMaterial", "onRevealMaterial", "onCopyMaterialPath", "onBringMaterialToFront", + "onRemoveMaterial", "onClose" + ].map((name) => [name, noop])) +}; +const paletteProps = { + locale: "en", sessions: [], launcherItems: [], + ...Object.fromEntries([ + "onFocusSession", "onLaunch", "onCreateRegion", "onCreateNote", "onAddFiles", "onPasteFiles", "onFitCanvas", + "onOpenBrowser", "onOpenFiles", "onOpenSettings", "onClose" + ].map((name) => [name, noop])) +}; + +test("a preloaded context menu and command palette render on their first open, with no Suspense wait", async (t) => { + const previousWindow = globalThis.window; + globalThis.window = { canvasTTY: { window: { isMacOS: false } } }; + t.after(() => { globalThis.window = previousWindow; }); + const bundle = await loadBundle(t); + const { createElement, renderToStaticMarkup, usePreloadedSurface, canvasContextMenuSurface, canvasCommandPaletteSurface } = bundle; + // The canvas's own pattern: `{open && Surface && }`. + const Host = ({ surface, open, props }) => { + const Surface = usePreloadedSurface(surface, open); + return open && Surface ? createElement(Surface, props) : null; + }; + const render = (surface, open, props) => renderToStaticMarkup(createElement(Host, { surface, open, props })); + + assert.equal(canvasContextMenuSurface.loaded(), null, "nothing is loaded at startup"); + assert.equal(render(canvasContextMenuSurface, true, menuProps), "", "an open before the preload renders nothing until its chunk arrives"); + + const [menuLoad, paletteLoad] = [canvasContextMenuSurface.load(), canvasCommandPaletteSurface.load()]; + assert.equal(canvasContextMenuSurface.load(), menuLoad, "one load per surface"); + await Promise.all([menuLoad, paletteLoad]); + + const menu = render(canvasContextMenuSurface, true, menuProps); + assert.match(menu, /^]*class="canvas-menu"/u, "the first render of the open menu contains the menu"); + assert.match(menu, /role="menuitem"/u); + const palette = render(canvasCommandPaletteSurface, true, paletteProps); + assert.match(palette, /canvas-command-palette/u, "the first render of the open palette contains the palette"); + assert.equal(render(canvasContextMenuSurface, false, menuProps), "", "a closed menu renders nothing"); +}); + +test("preloadWhenIdle loads after the first paint when the app is idle; a failed load is tried again", async (t) => { + const bundle = await loadBundle(t); + const { preloadedSurface, preloadWhenIdle } = bundle; + let calls = 0, fail = true; + const surface = preloadedSurface(async () => { + calls++; + if (fail) throw new Error("chunk failed"); + return "loaded"; + }); + const idle = []; + const previousWindow = globalThis.window; + globalThis.window = { requestIdleCallback: (callback, options) => { idle.push({ callback, options }); return idle.length; }, cancelIdleCallback: noop }; + t.after(() => { globalThis.window = previousWindow; }); + preloadWhenIdle([surface]); + assert.equal(calls, 0, "nothing loads before the browser is idle"); + assert.equal(idle.length, 1); + assert.ok(idle[0].options.timeout > 0, "bounded: it still loads on a busy start"); + idle[0].callback(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(calls, 1); + assert.equal(surface.loaded(), null, "a failed preload leaves nothing loaded"); + fail = false; + assert.equal(await surface.load(), "loaded", "the next open loads it again"); + assert.equal(calls, 2); + assert.equal(surface.loaded(), "loaded"); +}); + +test("the canvas renders the menu and the palette through the preloaded surfaces, not lazy() and Suspense", async () => { + const source = await readFile(join(root, "src/renderer/src/features/workspace/WorkspaceCanvas.tsx"), "utf8"); + assert.doesNotMatch(source, /lazy\(\(\) => import\("\.\/Canvas(ContextMenu|CommandPalette)"\)/u); + assert.doesNotMatch(source, /import \{ Canvas(ContextMenu|CommandPalette) \} from/u, "no static import either"); + assert.match(source, /usePreloadedSurface\(canvasContextMenuSurface, contextMenu !== null\)/u); + assert.match(source, /usePreloadedSurface\(canvasCommandPaletteSurface, commandPaletteOpen\)/u); + assert.match(source, /preloadWhenIdle\(\[canvasContextMenuSurface, canvasCommandPaletteSurface\]\)/u); + assert.match(source, /\{contextMenu && CanvasContextMenu && \(\n\s* /CanvasCommandPalette-/.test(p)); + assert.ok(paletteChunk, "the canvas command palette must be emitted as its own on-demand chunk"); + const contextMenuChunk = Object.keys(metafile.outputs).find((p) => /CanvasContextMenu-/.test(p)); + assert.ok(contextMenuChunk, "the canvas context menu must be emitted as its own on-demand chunk"); const { paths: eagerPaths, bytes: eagerBytes } = staticallyReachableBytes(metafile, appPath); assert.ok(!eagerPaths.has(settingsPath), "SettingsPanel must only be reachable through a dynamic import()"); assert.ok(!eagerPaths.has(launchDialogPath), "AgentLaunchDialog must only be reachable through a dynamic import()"); assert.ok(!eagerPaths.has(linkDialogPath), "TerminalLinkDialog must only be reachable through a dynamic import()"); + assert.ok(!eagerPaths.has(paletteChunk), "the command palette must only be reachable through a dynamic import()"); + assert.ok(!eagerPaths.has(contextMenuChunk), "the canvas context menu must only be reachable through a dynamic import()"); const settingsBytes = metafile.outputs[settingsPath].bytes; assert.ok(settingsBytes > 100_000, `SettingsPanel chunk should carry real weight, got ${settingsBytes} bytes`); From b2fd3e4c5117439a93863223dde8d2271939520b Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:02:47 +0000 Subject: [PATCH 2/3] feat(canvas): add Auto-sort to the empty-canvas Tools submenu Right-clicking empty canvas now shows a Tools row with one Auto-sort action. The criteria (by task, by status, by project, grid) are radio rows in that submenu, built with the existing canvas menu primitives. There is no separate panel, toolbar, presets or layouts list. - Scope: the current multi-selection when it holds two windows, otherwise terminal, plugin and Browser windows that are not completely inside a region. Companion mascots move only when selected. Sticky notes, materials and Files cards never move. - "By task" uses the orchestrator-to-subagent relation that already exists (parentSessionId): each subagent goes to the right of its parent. - The layout is deterministic: ties follow the current reading order, and sorting twice changes nothing. The block keeps the windows' top-left corner and keeps clear of HOME, regions, notes, materials, Files cards and windows it does not move (it shifts right or down, whichever is shorter, and never lands inside a region). With snapping on, every window lands on the 10px grid with the 20px snapping gap. - Moved windows get a presentation-only outline that clears on the next press or after a few seconds (they are not made the marquee selection), and a short glide that is skipped when reduced motion is requested. The Browser card does not glide, because its native view is placed from the measured card. - "Undo auto-sort" stays in the submenu while a sorted window is still where the sort put it. It restores only those windows. There is no global undo shortcut, so terminals and text inputs keep their keys. - The last criterion is kept in renderer memory for the running session, only after a sort happened. Nothing is persisted. A failed load or sort shows a localized toast. - The group-drag commit is extracted into commitLayerBounds and shared with auto-sort. - The Tools submenu body, scope and layout are not in the startup bundle: they load while the empty-canvas menu is open (or when Tools is pressed first), so the submenu still opens at once. Adds the official Lucide wrench and undo-2 icons, ru/en strings, and the UI contract text in en/ru/zh-CN. Tests drive the menu and the submenu. --- docs/UI_CONTRACT.md | 2 +- docs/UI_CONTRACT.ru.md | 1 + docs/UI_CONTRACT.zh-CN.md | 1 + .../src/assets/icons/lucide/README.md | 3 +- .../src/assets/icons/lucide/undo-2.svg | 14 + .../src/assets/icons/lucide/wrench.svg | 13 + src/renderer/src/components/UiIcon.tsx | 6 + .../features/workspace/CanvasAutoSortMenu.tsx | 108 ++++ .../features/workspace/CanvasContextMenu.tsx | 83 ++- .../features/workspace/WorkspaceCanvas.tsx | 88 ++- .../src/features/workspace/canvasAutoSort.ts | 305 ++++++++++ .../features/workspace/canvasAutoSortScope.ts | 109 ++++ src/renderer/src/lib/i18n.ts | 18 + src/renderer/src/styles/app.css | 8 + tests/canvas-auto-sort.test.mjs | 533 ++++++++++++++++++ tests/helpers/fake-react.mjs | 9 +- tests/settings-ui.test.mjs | 2 + 17 files changed, 1271 insertions(+), 32 deletions(-) create mode 100644 src/renderer/src/assets/icons/lucide/undo-2.svg create mode 100644 src/renderer/src/assets/icons/lucide/wrench.svg create mode 100644 src/renderer/src/features/workspace/CanvasAutoSortMenu.tsx create mode 100644 src/renderer/src/features/workspace/canvasAutoSort.ts create mode 100644 src/renderer/src/features/workspace/canvasAutoSortScope.ts create mode 100644 tests/canvas-auto-sort.test.mjs diff --git a/docs/UI_CONTRACT.md b/docs/UI_CONTRACT.md index 7acaf490..b8f529d8 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 33926a2d..97bdcf51 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 9baea3f0..374fae9f 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 ba3bcd5c..cddf4262 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 00000000..3e93594c --- /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 00000000..36243432 --- /dev/null +++ b/src/renderer/src/assets/icons/lucide/wrench.svg @@ -0,0 +1,13 @@ + + + diff --git a/src/renderer/src/components/UiIcon.tsx b/src/renderer/src/components/UiIcon.tsx index 27438ad1..d60f1f78 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 00000000..a0713674 --- /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 ( +
+
+ {t(locale, "canvasAutoSort")} + {CANVAS_AUTO_SORT_MODES.map((mode) => ( + + ))} + {!available && {t(locale, "canvasAutoSortUnavailable")}} +
+ {undoAvailable && ( + <> + + run(() => undoCanvasAutoSort(host))}> + {t(locale, "canvasAutoSortUndo")} + + + )} +
+ ); +} diff --git a/src/renderer/src/features/workspace/CanvasContextMenu.tsx b/src/renderer/src/features/workspace/CanvasContextMenu.tsx index a152cf7a..33c2c711 100644 --- a/src/renderer/src/features/workspace/CanvasContextMenu.tsx +++ b/src/renderer/src/features/workspace/CanvasContextMenu.tsx @@ -22,8 +22,21 @@ import { type CanvasContextMenuKind, type CanvasSubmenuSide } from "./canvasContextRouting"; +import type { CanvasAutoSortHost } from "./canvasAutoSortScope"; import { CANVAS_REGION_COLORS } from "./canvasRegions"; +type ToolsMenu = typeof import("./CanvasAutoSortMenu").CanvasAutoSortMenu; +/** The Tools submenu body: loaded when the empty-canvas menu opens (or by Tools) and kept for later menus. */ +let loadedToolsMenu: ToolsMenu | null = null; +let toolsMenuLoad: Promise | null = null; +function loadToolsMenu(): Promise { + toolsMenuLoad ??= import("./CanvasAutoSortMenu").then((module) => (loadedToolsMenu = module.CanvasAutoSortMenu), (error: unknown) => { + toolsMenuLoad = null; + throw error; + }); + return toolsMenuLoad; +} + interface CanvasContextMenuProps { kind: CanvasContextMenuKind; position: Point; @@ -52,6 +65,7 @@ interface CanvasContextMenuProps { onBringMaterialToFront(): void; onRemoveMaterial(): void; onSendRemarks: (() => void) | null; + autoSort: CanvasAutoSortHost; onClose(): void; } @@ -83,21 +97,30 @@ export function CanvasContextMenu({ onBringMaterialToFront, onRemoveMaterial, onSendRemarks, + autoSort, onClose }: CanvasContextMenuProps): React.JSX.Element { const menu = useRef(null); const [launcherOpen, setLauncherOpen] = useState(false); + const [toolsOpen, setToolsOpen] = useState(false); + const [ToolsMenu, setToolsMenu] = useState(() => loadedToolsMenu); + // Tools sits low in the menu: near the bottom of the canvas its submenu opens upward. + const [toolsUp, setToolsUp] = useState(false); const [submenuSide, setSubmenuSide] = useState("right"); useEffect(() => { menu.current?.querySelector(".canvas-menu__row")?.focus({ preventScroll: true }); }, [kind]); - const toggleLauncher = (): void => { - if (launcherOpen) { - setLauncherOpen(false); - return; - } + // Tools is opened from this menu: its body loads while the menu is open, so the submenu shows at once. + useEffect(() => { + if (kind !== "empty" || loadedToolsMenu) return; + let live = true; + loadToolsMenu().then((loaded) => { if (live) setToolsMenu(() => loaded); }, () => undefined); + return () => { live = false; }; + }, [kind]); + + const chooseSubmenuSide = (): void => { const root = menu.current; const workspace = root?.closest(".workspace"); if (root && workspace) { @@ -109,9 +132,41 @@ export function CanvasContextMenu({ .55 * fontSize )); } + }; + + const toggleLauncher = (): void => { + if (launcherOpen) { + setLauncherOpen(false); + return; + } + chooseSubmenuSide(); + setToolsOpen(false); setLauncherOpen(true); }; + const toggleTools = (event: React.MouseEvent): void => { + if (toolsOpen) { + setToolsOpen(false); + return; + } + chooseSubmenuSide(); + const workspace = menu.current?.closest(".workspace"); + const fontSize = Number.parseFloat(window.getComputedStyle(event.currentTarget).fontSize) || 13; + setToolsUp(Boolean(workspace) + && event.currentTarget.getBoundingClientRect().top + 22 * fontSize > workspace!.getBoundingClientRect().bottom); + setLauncherOpen(false); + setToolsOpen(true); + if (!loadedToolsMenu) { + loadToolsMenu().then((loaded) => { + setToolsMenu(() => loaded); + }).catch((error: unknown) => { + console.error("Auto-sort failed to load", error); + autoSort.onError(t(locale, "canvasAutoSortFailed")); + setToolsOpen(false); + }); + } + }; + return (
{t(locale, "files")} +
+ } + role="menuitem" + aria-haspopup="menu" + aria-expanded={toolsOpen} + onClick={toggleTools} + >{t(locale, "canvasMenuTools")} + {toolsOpen && ToolsMenu && ( + + )} +
= new Set(); +/** How long the windows an auto-sort moved keep their outline, unless the next press clears it. */ +const AUTO_SORT_MARK_MS = 4_000; +const NO_AUTO_SORT: CanvasAutoSortState = { mode: null, undo: null }; const NO_SNAP_TARGETS = (): readonly SessionBounds[] => []; /** The fullscreen layer is outside the scene: its card always draws at scale 1. */ const FULLSCREEN_CAMERA = fixedCameraStore({ x: 0, y: 0, zoom: 1 }); @@ -325,6 +329,12 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element const [regionMovePreview, setRegionMovePreview] = useState(null); const [marqueeSelection, setMarqueeSelection] = useState>(EMPTY_MARQUEE_SELECTION); + // Auto-sort state lives in renderer memory only: the last criterion for this app session and + // the single undo step. Nothing here is persisted. + const [autoSortState, setAutoSortState] = useState(NO_AUTO_SORT); + // The windows the last sort or undo moved: they glide and keep an outline for a moment. Outline + // only: unlike the marquee selection it never makes them a drag group or narrows the next sort. + const [autoSortMarked, setAutoSortMarked] = useState>(EMPTY_MARQUEE_SELECTION); const [masterPixelSkinSessionIds, setMasterPixelSkinSessionIds] = useState>(() => new Set()); const overlays = useRef(null); const lastPointerClient = useRef(null); @@ -535,6 +545,37 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element const groupDragBasis = useRef(null); + /** Persist new bounds for several layers, each by its own kind; materials go in one batch. */ + const commitLayerBounds = useCallback((next: ReadonlyMap): void => { + const materialBatch: { id: string; bounds: SessionBounds }[] = []; + for (const [layerId, moved] of next) { + const ref = parseCanvasLayerId(layerId); + if (!ref) continue; + // Commit each window by its own identity, including individual Browser cards. + if (ref.kind === "terminal" && ref.targetId !== null) onSessionBoundsChange(ref.targetId, moved); + else if (ref.kind === "plugin" && ref.targetId !== null) onPluginCanvasBoundsChange(ref.targetId, moved); + else if (ref.kind === "note" && ref.targetId !== null) onStickyNoteBoundsChange(ref.targetId, moved); + else if (ref.kind === "files" && ref.targetId !== null) onFileCardBoundsChange(ref.targetId, moved); + else if (ref.kind === "material" && ref.targetId !== null) materialBatch.push({ id: ref.targetId, bounds: moved }); + else if (ref.kind === "browser" && settings.browserCanvas) onBrowserBoundsChange({ ...settings.browserCanvas, ...moved }); + } + if (materialBatch.length > 0) onMaterialBoundsChangeBatch(materialBatch); + }, [onBrowserBoundsChange, onFileCardBoundsChange, onMaterialBoundsChangeBatch, onPluginCanvasBoundsChange, + onSessionBoundsChange, onStickyNoteBoundsChange, settings.browserCanvas]); + + // The outline of a sort clears after a moment, or at the next press anywhere on the canvas. + useEffect(() => { + if (autoSortMarked.size === 0) return; + const clear = (): void => setAutoSortMarked(EMPTY_MARQUEE_SELECTION); + const timer = window.setTimeout(clear, AUTO_SORT_MARK_MS); + const element = viewport.current; + element?.addEventListener("pointerdown", clear, true); + return () => { + window.clearTimeout(timer); + element?.removeEventListener("pointerdown", clear, true); + }; + }, [autoSortMarked]); + const beginGroupDrag = useCallback((layerId: string): void => { const anchor = boundsByLayer.get(layerId); if (!anchor) return; @@ -571,23 +612,11 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element x: anchorPosition.x - anchor.position.x, y: anchorPosition.y - anchor.position.y }; - const materialBatch: { id: string; bounds: SessionBounds }[] = []; - for (const [memberLayerId, memberBounds] of members) { - const moved = translateBounds(memberBounds, rigid); - const ref = parseCanvasLayerId(memberLayerId); - if (!ref) continue; - // Commit each window by its own identity, including individual Browser cards. - if (ref.kind === "terminal" && ref.targetId !== null) onSessionBoundsChange(ref.targetId, moved); - else if (ref.kind === "plugin" && ref.targetId !== null) onPluginCanvasBoundsChange(ref.targetId, moved); - else if (ref.kind === "note" && ref.targetId !== null) onStickyNoteBoundsChange(ref.targetId, moved); - else if (ref.kind === "files" && ref.targetId !== null) onFileCardBoundsChange(ref.targetId, moved); - else if (ref.kind === "material" && ref.targetId !== null) materialBatch.push({ id: ref.targetId, bounds: moved }); - else if (ref.kind === "browser" && settings.browserCanvas) onBrowserBoundsChange({ ...settings.browserCanvas, ...moved }); - } - if (materialBatch.length > 0) onMaterialBoundsChangeBatch(materialBatch); - }, [boundsByLayer, homeBounds, onBrowserBoundsChange, onFileCardBoundsChange, onMaterialBoundsChangeBatch, onPluginCanvasBoundsChange, - - onSessionBoundsChange, onStickyNoteBoundsChange, renderedCanvasRegions, settings.browserCanvas, settings.snapToGrid]); + commitLayerBounds(new Map([...members].map(([memberLayerId, memberBounds]) => [ + memberLayerId, + translateBounds(memberBounds, rigid) + ]))); + }, [boundsByLayer, commitLayerBounds, homeBounds, renderedCanvasRegions, settings.snapToGrid]); const focusController = useCanvasWidgetFocus({ viewport, @@ -790,7 +819,7 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element return clampCanvasMenuPosition( viewportPoint(clientX, clientY), { width: bounds.width, height: bounds.height }, - { width: 300 * settings.uiScale, height: 380 * settings.uiScale } + { width: 300 * settings.uiScale, height: 420 * settings.uiScale } ); }, [settings.uiScale, viewportPoint]); const worldPoint = useCallback((clientX: number, clientY: number): Point => { @@ -1053,7 +1082,7 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element ref={viewport} data-theme-background={themeBackground ?? (packBackground ? "custom" : undefined)} style={packBackground ? { backgroundImage: `url("${packBackground}")` } : undefined} - className={`workspace pattern-${settings.pattern} ${pointerNavigation.panning ? "workspace--panning" : ""} ${wheelNavigation.zooming ? "workspace--zooming" : ""} ${wheelNavigation.wheelPanning ? "workspace--wheel-panning" : ""} ${canvasOverrideActive ? "workspace--canvas-override" : ""}`} + className={`workspace pattern-${settings.pattern} ${pointerNavigation.panning ? "workspace--panning" : ""} ${wheelNavigation.zooming ? "workspace--zooming" : ""} ${wheelNavigation.wheelPanning ? "workspace--wheel-panning" : ""} ${canvasOverrideActive ? "workspace--canvas-override" : ""} ${autoSortMarked.size > 0 ? "workspace--auto-sorting" : ""}`} onPointerDownCapture={(event) => { if (openRadialLauncher(event)) return; const element = event.target as HTMLElement; @@ -1218,7 +1247,7 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element focusRevision={widgetFocus.id === terminalCanvasWidgetId(session.id) ? widgetFocus.revision : 0} selected={activeSessionId === session.id} forceMasterDetail={masterPixelSkinSessionIds.has(session.id)} - groupSelected={marqueeSelection.has(terminalLayerId(session.id))} + groupSelected={marqueeSelection.has(terminalLayerId(session.id)) || autoSortMarked.has(terminalLayerId(session.id))} renaming={renamingSessionId === session.id} fullscreen={fullscreenSessionId === session.id} hidden={homeEditing} @@ -1266,7 +1295,7 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element else focusController.cancelHover(pluginCanvasWidgetId(instance.id)); }} onCanvasWheel={wheelNavigation.applyCanvasWheel} - groupSelected={marqueeSelection.has(pluginLayerId(instance.id))} + groupSelected={marqueeSelection.has(pluginLayerId(instance.id)) || autoSortMarked.has(pluginLayerId(instance.id))} hidden={homeEditing} /> ); @@ -1305,7 +1334,7 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element onWidgetHoverChange={focusController.hoverBrowser} onClose={onCloseBrowser} onError={onPluginError} - groupSelected={marqueeSelection.has(browserLayerId)} + groupSelected={marqueeSelection.has(browserLayerId) || autoSortMarked.has(browserLayerId)} /> )} {renderedStickyNotes.map((note) => ( @@ -1547,6 +1576,21 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element setCommandPaletteOpen(false); onSendAllRemarks(); } : null} + autoSort={{ + bounds: boundsByLayer, + selection: marqueeSelection, + home: homeBounds, + regions: renderedCanvasRegions, + sessions, + pluginCanvas: renderedPluginCanvas, + plugins, + snapToGrid: settings.snapToGrid, + state: autoSortState, + setState: setAutoSortState, + commit: commitLayerBounds, + mark: setAutoSortMarked, + onError: onPluginError + }} onClose={() => setContextMenu(null)} /> )} diff --git a/src/renderer/src/features/workspace/canvasAutoSort.ts b/src/renderer/src/features/workspace/canvasAutoSort.ts new file mode 100644 index 00000000..6b76612f --- /dev/null +++ b/src/renderer/src/features/workspace/canvasAutoSort.ts @@ -0,0 +1,305 @@ +import type { Point, SessionBounds, SessionStatus } from "../../../../shared/contracts"; +import type { CanvasAutoSortMode } from "./canvasAutoSortScope"; + +/** + * Auto-sort for the empty-canvas Tools submenu. Pure geometry: the workspace collects the + * windows in scope (canvasAutoSortScope.ts), this module decides where they go, and the + * workspace commits the result through the same per-kind bounds callbacks a group drag uses. + * The workspace loads it on demand, the first time a criterion is chosen. + * + * Every arrangement is deterministic. Ties are broken by the current reading order (top to + * bottom, then left to right), then by start time, then by id, so sorting twice in a row is + * a no-op and the result does not depend on the order the windows were listed in. + */ +export interface CanvasAutoSortItem { + /** The canvas layer id (`data-canvas-layer-id`). */ + id: string; + bounds: SessionBounds; + /** Layer id of the session that spawned this one (orchestrator to subagent). */ + parentId?: string; + status?: SessionStatus; + /** Working folder; windows without one go to a trailing group. */ + project?: string; + startedAt?: number; +} + +/** The session fields auto-sort reads (a subset of SessionSnapshot). */ +export interface CanvasAutoSortSession { + id: string; + parentSessionId?: string; + status: SessionStatus; + cwd: string; + startedAt: number; +} + +/** What the block must stay clear of, and whether window snapping is on. */ +export interface CanvasAutoSortOptions { + /** Fixed areas the block may not cover, e.g. HOME and canvas regions. */ + avoid?: readonly SessionBounds[]; + snapToGrid?: boolean; +} + +/** + * Build the items for the windows in scope from their current bounds and arrange them. Returns + * null when fewer than two of them still exist. `layerIdOf` maps a session id to its layer id. + * Every layer in `bounds` that is not sorted (notes, materials, Files cards, windows left out of + * the scope) is an obstacle the sorted block keeps clear of, like `options.avoid`. + */ +export function planCanvasAutoSort( + ids: readonly string[], + bounds: ReadonlyMap, + sessions: readonly CanvasAutoSortSession[], + layerIdOf: (sessionId: string) => string, + mode: CanvasAutoSortMode, + options: CanvasAutoSortOptions +): { before: Map; after: Map } | null { + const sessionByLayer = new Map(sessions.map((session) => [layerIdOf(session.id), session])); + const items: CanvasAutoSortItem[] = []; + for (const id of new Set(ids)) { + const current = bounds.get(id); + if (!current) continue; + const session = sessionByLayer.get(id); + items.push({ + id, + bounds: { position: { ...current.position }, size: { ...current.size } }, + ...(session ? { + ...(session.parentSessionId ? { parentId: layerIdOf(session.parentSessionId) } : {}), + status: session.status, + project: session.cwd, + startedAt: session.startedAt + } : {}) + }); + } + if (items.length < 2) return null; + const sorted = new Set(items.map((item) => item.id)); + const obstacles = [...options.avoid ?? [], ...[...bounds].filter(([id]) => !sorted.has(id)).map(([, value]) => value)]; + return { + before: new Map(items.map((item) => [item.id, item.bounds])), + after: arrangeCanvasAutoSort(items, mode, { avoid: obstacles, snapToGrid: options.snapToGrid }) + }; +} + +/** The contract's snapping gap between neighbouring windows; criterion groups get twice that. */ +const GAP = 20; +const GROUP_GAP = 2 * GAP; +const GRID = 10; + +/** The next start after an edge: the gap past it, rounded up onto the grid when snapping. */ +type Step = (edge: number, gap?: number) => number; + +/** + * The new bounds of every item. The block starts at the top-left corner of the windows' current + * bounding box (on the grid when snapping), so the windows stay where the person was looking. If + * the block would cover anything in `options.avoid` it shifts right or down, whichever is the + * shorter move, until it is clear by at least the gap. With snapping every window lands on the + * grid. Sizes never change. + */ +export function arrangeCanvasAutoSort( + items: readonly CanvasAutoSortItem[], + mode: CanvasAutoSortMode, + options: CanvasAutoSortOptions = {} +): Map { + if (items.length === 0) return new Map(); + const snap = options.snapToGrid === true; + const step: Step = (edge, gap = GAP) => (snap ? Math.ceil((edge + gap) / GRID) * GRID : edge + gap); + const local = arrangeAt(items, mode, step); + const extent = boundingBox([...local.values()]).size; + const current = boundingBox(items.map((item) => item.bounds)).position; + const start = snap + ? { x: Math.round(current.x / GRID) * GRID, y: Math.round(current.y / GRID) * GRID } + : { x: Math.round(current.x), y: Math.round(current.y) }; + const origin = clearSpot(start, extent, options.avoid ?? [], step); + const result = new Map(); + for (const [id, bounds] of local) result.set(id, at(bounds.position.x + origin.x, bounds.position.y + origin.y, bounds)); + return result; +} + +/** + * The nearest place right of or below `start` where a block of `size`, grown by the gap, overlaps + * none of `obstacles`. Each sweep only ever moves past an obstacle's far edge, so it ends after at + * most one move per obstacle. + */ +function clearSpot(start: Point, size: SessionBounds["size"], obstacles: readonly SessionBounds[], step: Step): Point { + const sweep = (axis: "x" | "y"): Point => { + const spot = { ...start }; + for (let moves = 0; moves <= obstacles.length; moves += 1) { + const margin = { position: { x: spot.x - GAP, y: spot.y - GAP }, size: { width: size.width + 2 * GAP, height: size.height + 2 * GAP } }; + const hit = obstacles.find((obstacle) => overlaps(margin, obstacle)); + if (!hit) break; + spot[axis] = step(axis === "x" ? hit.position.x + hit.size.width : hit.position.y + hit.size.height); + } + return spot; + }; + const right = sweep("x"); + const down = sweep("y"); + return down.y - start.y < right.x - start.x ? down : right; +} + +function arrangeAt(items: readonly CanvasAutoSortItem[], mode: CanvasAutoSortMode, step: Step): Map { + const ordered = [...items].sort(readingOrder); + if (mode === "task") return arrangeTaskTree(ordered, step); + if (mode === "grid") return arrangeGrid(ordered, 0, step); + const groups = new Map(); + for (const item of ordered) { + const key = mode === "status" ? statusGroup(item.status) : projectGroup(item.project); + const group = groups.get(key) ?? []; + group.push(item); + groups.set(key, group); + } + const result = new Map(); + let x = 0; + for (const key of [...groups.keys()].sort(compareText)) { + let right = x; + for (const [id, bounds] of arrangeGrid(groups.get(key)!, x, step)) { + result.set(id, bounds); + right = Math.max(right, bounds.position.x + bounds.size.width); + } + x = step(right, GROUP_GAP); + } + return result; +} + +/** Orchestrators in a column, each subagent to the right of the session that spawned it. */ +function arrangeTaskTree(ordered: readonly CanvasAutoSortItem[], step: Step): Map { + const byId = new Map(ordered.map((item) => [item.id, item])); + const children = new Map(); + const roots: CanvasAutoSortItem[] = []; + for (const item of ordered) { + const parent = item.parentId ? byId.get(item.parentId) : undefined; + if (!parent || parent.id === item.id) { + roots.push(item); + continue; + } + const row = children.get(parent.id) ?? []; + row.push(item); + children.set(parent.id, row); + } + const depth = new Map(); + const markDepth = (item: CanvasAutoSortItem, level: number): void => { + if (depth.has(item.id)) return; + depth.set(item.id, level); + for (const child of children.get(item.id) ?? []) markDepth(child, level + 1); + }; + for (const root of roots) markDepth(root, 0); + // A malformed parent cycle has no natural root: break it at its first window in reading order. + for (const item of ordered) { + if (depth.has(item.id)) continue; + roots.push(item); + if (item.parentId) { + children.set(item.parentId, (children.get(item.parentId) ?? []).filter((child) => child.id !== item.id)); + } + markDepth(item, 0); + } + + const levelWidths: number[] = []; + for (const item of ordered) { + const level = depth.get(item.id) ?? 0; + levelWidths[level] = Math.max(levelWidths[level] ?? 0, item.bounds.size.width); + } + const levelX: number[] = [0]; + for (let level = 1; level < levelWidths.length; level += 1) { + levelX[level] = step(levelX[level - 1]! + (levelWidths[level - 1] ?? 0)); + } + + // A subtree's height measured the way `place` stacks it, so grid rounding never makes rows overlap. + const heights = new Map(); + const subtreeHeight = (item: CanvasAutoSortItem): number => { + const cached = heights.get(item.id); + if (cached !== undefined) return cached; + let top = 0; + let bottom = 0; + for (const child of children.get(item.id) ?? []) { + bottom = top + subtreeHeight(child); + top = step(bottom); + } + const height = Math.max(item.bounds.size.height, bottom); + heights.set(item.id, height); + return height; + }; + const result = new Map(); + const place = (item: CanvasAutoSortItem, top: number): void => { + result.set(item.id, at(levelX[depth.get(item.id) ?? 0] ?? 0, top, item.bounds)); + let childTop = top; + for (const child of children.get(item.id) ?? []) { + place(child, childTop); + childTop = step(childTop + subtreeHeight(child)); + } + }; + let top = 0; + for (const root of roots) { + place(root, top); + top = step(top + subtreeHeight(root)); + } + return result; +} + +/** A near-square grid; each column is as wide as its widest window and each row as tall as its tallest. */ +function arrangeGrid(ordered: readonly CanvasAutoSortItem[], left: number, step: Step): Map { + const columns = Math.max(1, Math.ceil(Math.sqrt(ordered.length))); + const rows = Math.ceil(ordered.length / columns); + const columnWidths = Array.from({ length: columns }, () => 0); + const rowHeights = Array.from({ length: rows }, () => 0); + ordered.forEach((item, index) => { + const row = Math.floor(index / columns); + const column = index % columns; + columnWidths[column] = Math.max(columnWidths[column]!, item.bounds.size.width); + rowHeights[row] = Math.max(rowHeights[row]!, item.bounds.size.height); + }); + const columnX = [left]; + for (let index = 1; index < columns; index += 1) columnX[index] = step(columnX[index - 1]! + columnWidths[index - 1]!); + const rowY = [0]; + for (let index = 1; index < rows; index += 1) rowY[index] = step(rowY[index - 1]! + rowHeights[index - 1]!); + return new Map(ordered.map((item, index) => [ + item.id, + at(columnX[index % columns]!, rowY[Math.floor(index / columns)]!, item.bounds) + ])); +} + +/** Columns left to right: what needs the person first, then what is idle, then what ended. */ +function statusGroup(status: SessionStatus | undefined): string { + switch (status) { + case "needs_approval": return "0"; + case "working": return "1"; + case "idle": return "2"; + case "done": return "3"; + case "failed": return "4"; + case "unavailable": return "5"; + default: return "6"; + } +} + +/** One column per working folder in path order; windows without a folder come last. */ +function projectGroup(project: string | undefined): string { + const normalized = project?.trim().replace(/(?<=.)[\\/]+$/u, "") ?? ""; + return normalized ? `0${normalized}` : "1"; +} + +function readingOrder(left: CanvasAutoSortItem, right: CanvasAutoSortItem): number { + return left.bounds.position.y - right.bounds.position.y + || left.bounds.position.x - right.bounds.position.x + || (left.startedAt ?? 0) - (right.startedAt ?? 0) + || compareText(left.id, right.id); +} + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function at(x: number, y: number, bounds: SessionBounds): SessionBounds { + return { position: { x, y }, size: { ...bounds.size } }; +} + +function boundingBox(bounds: readonly SessionBounds[]): SessionBounds { + const left = Math.min(...bounds.map((item) => item.position.x)); + const top = Math.min(...bounds.map((item) => item.position.y)); + const right = Math.max(...bounds.map((item) => item.position.x + item.size.width)); + const bottom = Math.max(...bounds.map((item) => item.position.y + item.size.height)); + return { position: { x: left, y: top }, size: { width: right - left, height: bottom - top } }; +} + +function overlaps(left: SessionBounds, right: SessionBounds): boolean { + return left.position.x < right.position.x + right.size.width + && left.position.x + left.size.width > right.position.x + && left.position.y < right.position.y + right.size.height + && left.position.y + left.size.height > right.position.y; +} diff --git a/src/renderer/src/features/workspace/canvasAutoSortScope.ts b/src/renderer/src/features/workspace/canvasAutoSortScope.ts new file mode 100644 index 00000000..18345e9e --- /dev/null +++ b/src/renderer/src/features/workspace/canvasAutoSortScope.ts @@ -0,0 +1,109 @@ +import type { SessionBounds } from "../../../../shared/contracts"; +import type { CanvasAutoSortSession } from "./canvasAutoSort"; + +/** + * What the empty-canvas Auto-sort may move and what its undo puts back. Kept apart from the + * arrangement itself (canvasAutoSort.ts) so the menu can tell whether a sort is possible without + * loading the layout code. + */ +export type CanvasAutoSortMode = "task" | "status" | "project" | "grid"; + +export const CANVAS_AUTO_SORT_MODES: readonly CanvasAutoSortMode[] = ["task", "status", "project", "grid"]; + +export type CanvasAutoSortKind = "terminal" | "plugin" | "browser" | "note" | "files" | "material"; + +export interface CanvasAutoSortCandidate { + id: string; + kind: CanvasAutoSortKind; + bounds: SessionBounds; + /** A companion-style plugin window (mascot): sorted only when explicitly selected. */ + decorative?: boolean; +} + +export interface CanvasAutoSortUndo { + mode: CanvasAutoSortMode; + before: ReadonlyMap; + after: ReadonlyMap; +} + +/** The last criterion (checked row) and the single undo step; renderer memory only. */ +export interface CanvasAutoSortState { + mode: CanvasAutoSortMode | null; + undo: CanvasAutoSortUndo | null; +} + +/** What the workspace hands the Tools submenu: the layout of this moment and how to change it. */ +export interface CanvasAutoSortHost { + /** Bounds of every rendered layer, by layer id. */ + bounds: ReadonlyMap; + selection: ReadonlySet; + home: SessionBounds; + regions: readonly SessionBounds[]; + sessions: readonly CanvasAutoSortSession[]; + /** Plugin windows and installed plugins, to tell companion mascots apart. */ + pluginCanvas: readonly { id: string; pluginId: string }[]; + plugins: readonly { manifest: { id: string }; sourceUrl: string }[]; + snapToGrid: boolean; + state: CanvasAutoSortState; + setState(state: CanvasAutoSortState): void; + /** Persist new bounds through the same per-kind callbacks a group drag uses. */ + commit(next: ReadonlyMap): void; + /** Outline the windows that just moved (presentation only). */ + mark(ids: ReadonlySet): void; + onError(message: string): void; +} + +/** Notes, materials and file cards are annotations whose place carries meaning: never moved. */ +const MOVABLE_KINDS: ReadonlySet = new Set(["terminal", "plugin", "browser"]); + +/** + * Which windows an auto-sort moves. A marquee selection holding two or more movable windows + * limits the sort to them. Otherwise (no selection, or one holding only notes, materials, Files + * cards or a single window) every terminal, plugin and Browser window that is not completely + * inside a canvas region moves: regions hold the person's own grouping, and moving a window out + * of one silently changes its membership. + */ +export function canvasAutoSortScope( + candidates: readonly CanvasAutoSortCandidate[], + selection: ReadonlySet, + regions: readonly SessionBounds[] +): string[] { + const movable = candidates.filter((candidate) => MOVABLE_KINDS.has(candidate.kind)); + const selected = movable.filter((candidate) => selection.has(candidate.id)); + if (selected.length >= 2) return selected.map((candidate) => candidate.id); + return movable + .filter((candidate) => !candidate.decorative) + .filter((candidate) => !regions.some((region) => contains(region, candidate.bounds))) + .map((candidate) => candidate.id); +} + +/** + * What an undo puts back: only windows that still sit exactly where the sort placed them. + * A window the person moved or resized afterwards keeps its new place; a closed one is skipped. + */ +export function canvasAutoSortUndoTargets( + undo: CanvasAutoSortUndo, + current: ReadonlyMap +): Map { + const result = new Map(); + for (const [id, before] of undo.before) { + const placed = undo.after.get(id); + const now = current.get(id); + if (placed && now && sameBounds(placed, now) && !sameBounds(before, now)) result.set(id, before); + } + return result; +} + +function contains(outer: SessionBounds, inner: SessionBounds): boolean { + return inner.position.x >= outer.position.x + && inner.position.y >= outer.position.y + && inner.position.x + inner.size.width <= outer.position.x + outer.size.width + && inner.position.y + inner.size.height <= outer.position.y + outer.size.height; +} + +function sameBounds(left: SessionBounds, right: SessionBounds): boolean { + return left.position.x === right.position.x + && left.position.y === right.position.y + && left.size.width === right.size.width + && left.size.height === right.size.height; +} diff --git a/src/renderer/src/lib/i18n.ts b/src/renderer/src/lib/i18n.ts index b69e9190..b7598f61 100644 --- a/src/renderer/src/lib/i18n.ts +++ b/src/renderer/src/lib/i18n.ts @@ -561,6 +561,15 @@ const ru = { canvasMenuCreateRegion: "Создать регион", canvasMenuLaunchAgent: "Запустить агента", canvasMenuOpenBrowser: "Открыть браузер", + canvasMenuTools: "Инструменты", + canvasAutoSort: "Автосортировка", + canvasAutoSortTask: "По задачам", + canvasAutoSortStatus: "По статусу", + canvasAutoSortProject: "По проекту", + canvasAutoSortGrid: "Сеткой", + canvasAutoSortUnavailable: "нужно минимум 2 окна", + canvasAutoSortUndo: "Отменить автосортировку", + canvasAutoSortFailed: "Автосортировка не удалась", canvasMenuRenameRegion: "Переименовать", canvasMenuNoteInRegion: "Заметка в регион", canvasMenuDeleteRegion: "Удалить регион", @@ -1585,6 +1594,15 @@ const en: Record = { canvasMenuCreateRegion: "Create region", canvasMenuLaunchAgent: "Launch agent", canvasMenuOpenBrowser: "Open browser", + canvasMenuTools: "Tools", + canvasAutoSort: "Auto-sort", + canvasAutoSortTask: "By task", + canvasAutoSortStatus: "By status", + canvasAutoSortProject: "By project", + canvasAutoSortGrid: "Grid", + canvasAutoSortUnavailable: "needs at least 2 windows", + canvasAutoSortUndo: "Undo auto-sort", + canvasAutoSortFailed: "Auto-sort failed", canvasMenuRenameRegion: "Rename", canvasMenuNoteInRegion: "Note in region", canvasMenuDeleteRegion: "Delete region", diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index f4786c22..7a31e809 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -398,11 +398,15 @@ button { border: 0; } .canvas-menu__kbd { padding: .2em .55em; border-radius: .4em; color: var(--text-dark); background: var(--secondary); font: 400 .78em/1.2 var(--font-mono); white-space: nowrap; } .canvas-menu__sub { color: var(--text-muted-on-dark); font-size: .92em; font-weight: 400; white-space: nowrap; } .canvas-menu__chevron { display: inline-flex; color: var(--text-muted-on-dark); } +.canvas-menu__check { display: inline-flex; color: var(--primary); } +.canvas-menu__row:disabled { cursor: default; opacity: .5; } +.canvas-menu__row:disabled:hover { background: transparent; } .canvas-menu__provider { min-width: 0; display: inline-flex; align-items: center; gap: .75em; overflow: hidden; text-overflow: ellipsis; } .canvas-menu__provider .provider-icon--small { --provider-icon-art-size: 1em; width: 1.1em; height: 1.1em; } .canvas-menu__submenu-anchor { position: relative; } .canvas-menu__submenu { top: -.45em; left: calc(100% + .55em); z-index: 1; width: 19em; min-width: 19em; margin: 0; } .canvas-menu__submenu--left { right: calc(100% + .55em); left: auto; } +.canvas-menu__submenu--up { top: auto; bottom: -.45em; } .canvas-menu__palette-row { cursor: default; } .canvas-menu__swatches { display: flex; gap: .45em; padding: .15em .3em; } .canvas-menu__swatch { width: 1.5em; height: 1.5em; flex: 0 0 auto; border: .08em solid color-mix(in srgb, var(--surface) 75%, var(--text)); border-radius: .45em; outline: 0; cursor: pointer; } @@ -676,6 +680,10 @@ button { border: 0; } .plugin-canvas-card--summary .plugin-canvas-card__summary { display: flex; } .browser-card { --card-header-height: calc(54px * var(--ui-scale, 1)); position: absolute; left: 0; top: 0; width: 920px; height: 620px; overflow: hidden; border: 1px solid rgba(255,255,255,.12); border-radius: 17px; color: white; background: #272934; box-shadow: var(--shadow-lg); transition: box-shadow .15s ease; } +/* Auto-sort: sorted windows glide to their new place, without motion when reduced motion is requested. The Browser + card jumps: its native page view is placed from the card's measured rect and would stay at the start of a glide. */ +.workspace--auto-sorting :is(.terminal-card, .plugin-canvas-card) { transition: transform .24s ease, box-shadow .15s ease; } +@media (prefers-reduced-motion: reduce) { .workspace--auto-sorting :is(.terminal-card, .plugin-canvas-card) { transition: none; } } .browser-card:focus-within, .browser-card--selected { box-shadow: 0 0 0 3px var(--primary), var(--shadow-lg); } .browser-card__header { height: var(--card-header-height); padding: 0 calc(11px * var(--ui-scale, 1)) 0 calc(15px * var(--ui-scale, 1)); display: flex; align-items: center; justify-content: space-between; gap: calc(12px * var(--ui-scale, 1)); color: white; background: var(--surface-soft); cursor: move; font-size: calc(13px * var(--ui-scale, 1)); touch-action: none; } .browser-card__title { min-width: 0; display: flex; align-items: center; gap: .77em; } diff --git a/tests/canvas-auto-sort.test.mjs b/tests/canvas-auto-sort.test.mjs new file mode 100644 index 00000000..d38065a9 --- /dev/null +++ b/tests/canvas-auto-sort.test.mjs @@ -0,0 +1,533 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { findAll, importWithFakeReact, tick } from "./helpers/fake-react.mjs"; +import { arrangeCanvasAutoSort, planCanvasAutoSort } from "../src/renderer/src/features/workspace/canvasAutoSort.ts"; +import { + CANVAS_AUTO_SORT_MODES, + canvasAutoSortScope, + canvasAutoSortUndoTargets +} from "../src/renderer/src/features/workspace/canvasAutoSortScope.ts"; + +const source = (path) => readFile(new URL(`../${path}`, import.meta.url), "utf8"); +const card = (x = 0, y = 0, width = 700, height = 430) => ({ position: { x, y }, size: { width, height } }); +const overlaps = (a, b) => a.position.x < b.position.x + b.size.width + && a.position.x + a.size.width > b.position.x + && a.position.y < b.position.y + b.size.height + && a.position.y + a.size.height > b.position.y; +const assertDisjoint = (placements) => { + const entries = [...placements.entries()]; + for (let i = 0; i < entries.length; i += 1) { + for (let j = i + 1; j < entries.length; j += 1) { + assert.equal(overlaps(entries[i][1], entries[j][1]), false, `${entries[i][0]} overlaps ${entries[j][0]}`); + } + } +}; +const HOME = card(0, 0, 1_600, 1_200); +const GAP = 20; +const grown = (bounds, by) => card(bounds.position.x - by, bounds.position.y - by, bounds.size.width + 2 * by, bounds.size.height + 2 * by); +const blockOf = (placements) => { + const all = [...placements.values()]; + const left = Math.min(...all.map((b) => b.position.x)); + const top = Math.min(...all.map((b) => b.position.y)); + return card(left, top, Math.max(...all.map((b) => b.position.x + b.size.width)) - left, + Math.max(...all.map((b) => b.position.y + b.size.height)) - top); +}; +const STATUSES = ["working", "needs_approval", "done", "failed", "idle", "unavailable"]; + +function mixedItems(count) { + return Array.from({ length: count }, (_, index) => ({ + id: `terminal:s${index}`, + bounds: card(2_000 + (index % 7) * 97, 300 + (index % 5) * 61, 420 + (index % 4) * 90, 260 + (index % 5) * 70), + ...(index > 0 && index % 3 !== 0 ? { parentId: `terminal:s${Math.floor((index - 1) / 3)}` } : {}), + status: STATUSES[index % STATUSES.length], + project: ["/work/one", "/work/two/", "/work/three"][index % 3], + startedAt: 1_000 + index + })); +} + +test("every criterion keeps mixed-size windows disjoint, sizes unchanged, and clear of HOME", () => { + for (let count = 1; count <= 40; count += 1) { + const items = mixedItems(count); + for (const mode of CANVAS_AUTO_SORT_MODES) { + const result = arrangeCanvasAutoSort(items, mode, { avoid: [HOME] }); + assert.deepEqual([...result.keys()].sort(), items.map((item) => item.id).sort()); + assertDisjoint(result); + for (const item of items) { + assert.deepEqual(result.get(item.id).size, item.bounds.size, `${mode} keeps ${item.id} size`); + assert.equal(overlaps(result.get(item.id), HOME), false, `${mode} keeps ${item.id} off HOME`); + } + } + } +}); + +test("auto-sort is deterministic: input order does not matter and sorting twice changes nothing", () => { + const items = mixedItems(13); + const reversed = [...items].reverse(); + for (const mode of CANVAS_AUTO_SORT_MODES) { + const first = arrangeCanvasAutoSort(items, mode, { avoid: [HOME], snapToGrid: true }); + assert.deepEqual(arrangeCanvasAutoSort(reversed, mode, { avoid: [HOME], snapToGrid: true }), first, `${mode} ignores list order`); + const again = arrangeCanvasAutoSort( + items.map((item) => ({ ...item, bounds: first.get(item.id) })), + mode, + { avoid: [HOME], snapToGrid: true } + ); + assert.deepEqual(again, first, `${mode} is idempotent`); + } +}); + +test("the block stays at the windows' top-left corner and moves off HOME by the shorter way only when it would cover it", () => { + const away = [ + { id: "a", bounds: card(3_013, 517) }, + { id: "b", bounds: card(5_000, 900) } + ]; + const kept = arrangeCanvasAutoSort(away, "grid", { avoid: [HOME] }); + assert.deepEqual(kept.get("a").position, { x: 3_013, y: 517 }); + const snapped = arrangeCanvasAutoSort(away, "grid", { avoid: [HOME], snapToGrid: true }); + assert.deepEqual(snapped.get("a").position, { x: 3_010, y: 520 }); + + // Two 700-wide cards side by side: leaving HOME downwards (1,120) is shorter than to the right (1,520). + const onHome = [ + { id: "a", bounds: card(100, 100) }, + { id: "b", bounds: card(200, 900) } + ]; + const moved = arrangeCanvasAutoSort(onHome, "grid", { avoid: [HOME] }); + assert.deepEqual(blockOf(moved).position, { x: 100, y: HOME.size.height + GAP }); + // A tall column next to HOME's right edge leaves to the right. + const tall = arrangeCanvasAutoSort([ + { id: "a", bounds: card(1_500, 0, 300, 2_000) }, + { id: "b", bounds: card(1_500, 2_100, 300, 2_000) } + ], "grid", { avoid: [HOME] }); + assert.deepEqual(blockOf(tall).position, { x: HOME.size.width + GAP, y: 0 }); +}); + +test("with snapping the block is snapped first, then checked against HOME, then moved off it onto the grid", () => { + for (const [x, y] of [[1_585, 40], [1_596, 40], [1_612, 333], [1_619, 0], [40, 1_185], [333, 1_214]]) { + const items = [{ id: "a", bounds: card(x, y, 455, 333) }, { id: "b", bounds: card(x + 7, y + 900, 455, 333) }]; + for (const mode of CANVAS_AUTO_SORT_MODES) { + const result = arrangeCanvasAutoSort(items, mode, { avoid: [HOME], snapToGrid: true }); + for (const bounds of result.values()) { + assert.equal(bounds.position.x % 10, 0, `${mode} from ${x},${y}: x on the grid`); + assert.equal(bounds.position.y % 10, 0, `${mode} from ${x},${y}: y on the grid`); + assert.equal(overlaps(grown(bounds, GAP), HOME), false, `${mode} from ${x},${y}: the gap to HOME is kept`); + } + } + } + // Already clear of HOME by the gap once snapped: the block stays where it is. + const clear = arrangeCanvasAutoSort([ + { id: "a", bounds: card(1_624, 0) }, { id: "b", bounds: card(1_624, 600) } + ], "grid", { avoid: [HOME], snapToGrid: true }); + assert.deepEqual(blockOf(clear).position, { x: 1_620, y: 0 }); +}); + +test("with snapping every window lands on the 10px grid with at least the 20px snapping gap", () => { + for (let count = 2; count <= 40; count += 1) { + const items = mixedItems(count).map((item, index) => ({ + ...item, + bounds: card(item.bounds.position.x + 3, item.bounds.position.y + 7, item.bounds.size.width + (index % 9), item.bounds.size.height + (index % 7)) + })); + for (const mode of CANVAS_AUTO_SORT_MODES) { + const result = arrangeCanvasAutoSort(items, mode, { avoid: [HOME], snapToGrid: true }); + const entries = [...result.entries()]; + for (const [id, bounds] of entries) { + assert.equal(bounds.position.x % 10, 0, `${mode} ${id} x`); + assert.equal(bounds.position.y % 10, 0, `${mode} ${id} y`); + } + for (let i = 0; i < entries.length; i += 1) { + for (let j = i + 1; j < entries.length; j += 1) { + assert.equal(overlaps(grown(entries[i][1], GAP), entries[j][1]), false, `${mode}: ${entries[i][0]} and ${entries[j][0]} keep the gap`); + } + } + } + } + const neighbours = arrangeCanvasAutoSort([{ id: "a", bounds: card(3_000, 0) }, { id: "b", bounds: card(3_000, 600) }], "grid", {}); + assert.equal(neighbours.get("b").position.x - (neighbours.get("a").position.x + 700), GAP, "neighbours sit exactly one gap apart"); +}); + +test("the block keeps clear of regions, notes and windows it does not move, and never lands inside a region", () => { + const layerIdOf = (id) => `terminal:${id}`; + const region = card(3_000, 0, 1_000, 3_000); + const bounds = new Map([ + ["terminal:a", card(2_400, 100, 500, 300)], + ["terminal:b", card(2_400, 600, 500, 300)], + ["note:n", card(2_000, 1_300, 300, 200)], + ["terminal:kept", card(2_000, 1_700, 500, 300)] + ]); + const plan = planCanvasAutoSort(["terminal:a", "terminal:b"], bounds, [], layerIdOf, "grid", { avoid: [HOME, region], snapToGrid: true }); + const block = blockOf(plan.after); + for (const obstacle of [HOME, region, bounds.get("note:n"), bounds.get("terminal:kept")]) { + assert.equal(overlaps(grown(block, GAP), obstacle), false, `clear of ${JSON.stringify(obstacle)}`); + } + assert.ok(block.position.x >= region.position.x + region.size.width || block.position.y >= region.position.y + region.size.height, + "the block is past the region, not inside it"); + + // A region all around the windows' corner: the block still ends outside it. + const around = card(1_900, -100, 3_000, 3_000); + const inside = planCanvasAutoSort(["terminal:a", "terminal:b"], bounds, [], layerIdOf, "grid", { avoid: [around] }); + assert.equal(overlaps(blockOf(inside.after), around), false); +}); + +test("by task puts each subagent to the right of the session that spawned it and survives parent cycles", () => { + const items = [ + { id: "terminal:lead", bounds: card(0, 2_000) }, + { id: "terminal:sub-b", parentId: "terminal:lead", bounds: card(900, 2_900) }, + { id: "terminal:sub-a", parentId: "terminal:lead", bounds: card(-500, 2_100) }, + { id: "terminal:solo", bounds: card(300, 1_500) }, + { id: "terminal:orphan", parentId: "terminal:closed", bounds: card(4_000, 4_000) } + ]; + const result = arrangeCanvasAutoSort(items, "task", {}); + const lead = result.get("terminal:lead"); + for (const sub of ["terminal:sub-a", "terminal:sub-b"]) { + assert.ok(result.get(sub).position.x >= lead.position.x + lead.size.width, `${sub} is right of its parent`); + } + assert.ok(result.get("terminal:sub-a").position.y < result.get("terminal:sub-b").position.y, "siblings keep reading order"); + assert.equal(result.get("terminal:orphan").position.x, result.get("terminal:solo").position.x, "a missing parent makes a root"); + assert.ok(result.get("terminal:solo").position.y < lead.position.y, "roots keep reading order"); + + const cycle = arrangeCanvasAutoSort([ + { id: "x", parentId: "y", bounds: card(0, 0) }, + { id: "y", parentId: "x", bounds: card(0, 600) } + ], "task", {}); + assert.equal(cycle.size, 2); + assertDisjoint(cycle); +}); + +test("status columns run attention, working, idle, done, failed, unavailable; project columns merge trailing slashes", () => { + const order = ["unavailable", "failed", "done", "idle", "working", "needs_approval"]; + const items = order.map((status, index) => ({ id: status, status, bounds: card(index * 800, 3_000) })); + const result = arrangeCanvasAutoSort(items, "status", {}); + const columns = [...items].sort((a, b) => result.get(a.id).position.x - result.get(b.id).position.x).map((item) => item.id); + assert.deepEqual(columns, ["needs_approval", "working", "idle", "done", "failed", "unavailable"]); + + const projects = arrangeCanvasAutoSort([ + { id: "browser", bounds: card(0, 0) }, + { id: "b1", project: "/work/b/", bounds: card(0, 500) }, + { id: "a1", project: "/work/a", bounds: card(0, 1_000) }, + { id: "b2", project: "/work/b", bounds: card(0, 1_500) } + ], "project", {}); + const right = (id) => projects.get(id).position.x + projects.get(id).size.width; + for (const id of ["b1", "b2"]) { + assert.ok(projects.get(id).position.x > right("a1"), `${id} is in the /work/b group after /work/a`); + assert.ok(right(id) < projects.get("browser").position.x, "windows without a folder come last"); + } + assert.equal(projects.get("b1").position.y, projects.get("b2").position.y, "/work/b and /work/b/ form one group"); +}); + +test("a plan reads session relations and the bounds of that moment, and needs two live windows", () => { + const layerIdOf = (id) => `terminal:${id}`; + const sessions = [ + { id: "lead", status: "working", cwd: "/work", startedAt: 1 }, + { id: "sub", parentSessionId: "lead", status: "needs_approval", cwd: "/work", startedAt: 2 } + ]; + const bounds = new Map([["terminal:lead", card(3_000, 0)], ["terminal:sub", card(3_000, 900)], ["browser", card(3_000, 1_800)]]); + const plan = planCanvasAutoSort(["terminal:lead", "terminal:sub", "browser", "terminal:closed"], bounds, sessions, layerIdOf, "task", { avoid: [HOME] }); + assert.deepEqual([...plan.before.keys()], ["terminal:lead", "terminal:sub", "browser"]); + assert.deepEqual(plan.before.get("terminal:sub"), card(3_000, 900)); + assert.notEqual(plan.before.get("terminal:sub"), bounds.get("terminal:sub"), "undo keeps its own copy"); + assert.ok(plan.after.get("terminal:sub").position.x > plan.after.get("terminal:lead").position.x, "subagent right of its lead"); + assert.equal(planCanvasAutoSort(["terminal:lead", "terminal:closed"], bounds, sessions, layerIdOf, "grid", {}), null); +}); + +test("scope: a selection limits the sort; otherwise windows outside regions, never notes, materials or Files cards", () => { + const region = card(5_000, 0, 2_000, 2_000); + const candidates = [ + { id: "terminal:a", kind: "terminal", bounds: card(0, 0) }, + { id: "terminal:in-region", kind: "terminal", bounds: card(5_100, 100) }, + { id: "plugin:app", kind: "plugin", bounds: card(0, 800) }, + { id: "plugin:mascot", kind: "plugin", bounds: card(0, 1_600), decorative: true }, + { id: "browser", kind: "browser", bounds: card(900, 0) }, + { id: "note:n", kind: "note", bounds: card(0, 3_000) }, + { id: "material:m", kind: "material", bounds: card(0, 4_000) }, + { id: "files:f", kind: "files", bounds: card(0, 5_000) } + ]; + assert.deepEqual(canvasAutoSortScope(candidates, new Set(), [region]), ["terminal:a", "plugin:app", "browser"]); + assert.deepEqual(canvasAutoSortScope(candidates, new Set(["terminal:a"]), [region]), ["terminal:a", "plugin:app", "browser"], + "a single selected card is not a group"); + assert.deepEqual( + canvasAutoSortScope(candidates, new Set(["terminal:in-region", "plugin:mascot", "note:n", "files:f"]), [region]), + ["terminal:in-region", "plugin:mascot"], + "an explicit selection includes region contents and mascots but still never notes or Files cards" + ); + const fallback = ["terminal:a", "plugin:app", "browser"]; + assert.deepEqual(canvasAutoSortScope(candidates, new Set(["note:n", "files:f"]), [region]), fallback, + "a selection without two windows (two notes) falls back to the default scope"); + assert.deepEqual(canvasAutoSortScope(candidates, new Set(["terminal:in-region", "note:n", "material:m"]), [region]), fallback, + "one selected window plus annotations falls back too"); +}); + +test("undo restores only windows still where the sort put them", () => { + const before = new Map([["a", card(0, 0)], ["b", card(0, 600)], ["c", card(0, 1_200)], ["d", card(0, 1_800)]]); + const after = new Map([["a", card(2_000, 0)], ["b", card(2_800, 0)], ["c", card(3_600, 0)], ["d", card(0, 1_800)]]); + const current = new Map([ + ["a", card(2_000, 0)], + ["b", card(2_810, 0)], + ["d", card(0, 1_800)] + ]); + const restore = canvasAutoSortUndoTargets({ mode: "grid", before, after }, current); + assert.deepEqual([...restore.keys()], ["a"], "moved (b), closed (c) and unmoved (d) windows are left alone"); + assert.deepEqual(restore.get("a"), card(0, 0)); +}); + +const menuModule = await importWithFakeReact("src/renderer/src/features/workspace/CanvasContextMenu.tsx", "CanvasContextMenu"); +const toolsModule = await importWithFakeReact("src/renderer/src/features/workspace/CanvasAutoSortMenu.tsx", "CanvasAutoSortMenu"); +const brokenMenuModule = await importWithFakeReact("src/renderer/src/features/workspace/CanvasContextMenu.tsx", "CanvasContextMenu", { + plugins: [{ + name: "broken-tools-chunk", + setup(builder) { + builder.onResolve({ filter: /CanvasAutoSortMenu$/ }, () => ({ path: "broken", namespace: "broken" })); + builder.onLoad({ filter: /.*/, namespace: "broken" }, () => ({ contents: 'throw new Error("chunk failed");', loader: "js" })); + } + }] +}); + +const terminal = (id, x, y, extra = {}) => [`terminal:${id}`, card(x, y), { id, status: "idle", cwd: "/work", startedAt: 1, ...extra }]; + +/** A workspace host: the layers of the moment plus spies for what the Tools submenu asks of it. */ +function host(windows, other = {}) { + const calls = { commit: [], state: [], mark: [], error: [] }; + const value = { + bounds: new Map([...windows.map(([id, bounds]) => [id, bounds]), ...(other.layers ?? [])]), + selection: other.selection ?? new Set(), + home: HOME, + regions: other.regions ?? [], + sessions: windows.map(([, , session]) => session).filter(Boolean), + pluginCanvas: other.pluginCanvas ?? [], + plugins: other.plugins ?? [], + snapToGrid: other.snapToGrid ?? true, + state: other.state ?? { mode: null, undo: null }, + setState: (state) => calls.state.push(state), + commit: (next) => calls.commit.push(next), + mark: (ids) => calls.mark.push([...ids]), + onError: (message) => calls.error.push(message) + }; + return { value, calls }; +} + +function menuHarness(t, props = {}, module = menuModule) { + const oldWindow = globalThis.window; + globalThis.window = { canvasTTY: { window: { isMacOS: false } }, getComputedStyle: () => ({ fontSize: "13px" }) }; + module.__reset(); + const oldError = console.error; + console.error = () => undefined; + t.after(() => { module.__unmount(); globalThis.window = oldWindow; console.error = oldError; }); + let closed = 0; + const noop = () => undefined; + const all = new Proxy({ + kind: "empty", position: { x: 0, y: 0 }, locale: "en", launcherItems: [], currentRegionColor: null, + materialHasLocation: false, onSendMaterial: null, onSendRemarks: null, + autoSort: host([]).value, onClose: () => { closed += 1; }, + ...props + }, { get: (target, key) => (key in target ? target[key] : noop) }); + let tree; + const render = () => { module.__flush(); tree = module.__render(module.CanvasContextMenu, all); return tree; }; + const row = (label) => findAll(tree, (node) => node.props?.role === "menuitem" && node.props.children === label)[0]; + const tools = () => findAll(tree, (node) => node.type?.name === "CanvasAutoSortMenu")[0]; + const openTools = async () => { + row(props.locale === "ru" ? "Инструменты" : "Tools").props.onClick({ currentTarget: { getBoundingClientRect: () => ({ top: 0 }) } }); + for (let i = 0; i < 3; i += 1) { render(); await tick(); } + render(); + }; + render(); + return { render, row, tools, openTools, closed: () => closed }; +} + +function toolsMenu(hostValue, locale = "en") { + let done = 0; + const tree = toolsModule.CanvasAutoSortMenu({ host: hostValue, locale, className: "canvas-menu", onDone: () => { done += 1; } }); + const rows = findAll(tree, (node) => typeof node.props?.role === "string" && node.props.role.startsWith("menuitem")); + return { + tree, + rows, + radios: rows.filter((node) => node.props.role === "menuitemradio"), + row: (label) => rows.find((node) => node.props.children === label), + text: JSON.stringify(tree), + done: () => done + }; +} + +test("the empty-canvas menu has one Tools row that loads its submenu on demand", async (t) => { + const sortHost = host([]).value; + const ui = menuHarness(t, { autoSort: sortHost }); + assert.ok(ui.row("Tools"), "Tools is in the empty-canvas menu"); + assert.equal(ui.row("Tools").props["aria-expanded"], false); + assert.equal(ui.tools(), undefined, "the submenu is not rendered until Tools opens"); + await ui.openTools(); + assert.equal(ui.row("Tools").props["aria-expanded"], true); + const tools = ui.tools(); + assert.ok(tools, "the loaded submenu is shown"); + assert.equal(tools.props.host, sortHost); + assert.match(tools.props.className, /canvas-menu__submenu canvas-menu__submenu--right/); + tools.props.onDone(); + assert.equal(ui.closed(), 1, "running an action closes the menu"); + + for (const kind of ["region", "note", "material"]) { + assert.equal(menuHarness(t, { kind }).row("Tools"), undefined, `no Tools row in the ${kind} menu`); + } +}); + +test("the Tools submenu body loads while the empty-canvas menu is open, so Tools shows it at once", async (t) => { + // A fresh copy of the menu module: nothing loaded yet. + const fresh = await importWithFakeReact("src/renderer/src/features/workspace/CanvasContextMenu.tsx", "CanvasContextMenu"); + const ui = menuHarness(t, {}, fresh); + for (let i = 0; i < 3; i += 1) { ui.render(); await tick(); } + ui.row("Tools").props.onClick({ currentTarget: { getBoundingClientRect: () => ({ top: 0 }) } }); + ui.render(); + assert.ok(ui.tools(), "the first render after Tools already shows the submenu"); +}); + +test("opening Tools closes the launcher submenu and the other way round", async (t) => { + const ui = menuHarness(t); + await ui.openTools(); + ui.row("Launch agent").props.onClick(); + ui.render(); + assert.equal(ui.row("Tools").props["aria-expanded"], false); + assert.equal(ui.row("Launch agent").props["aria-expanded"], true); + await ui.openTools(); + assert.equal(ui.row("Launch agent").props["aria-expanded"], false); +}); + +test("if the Tools submenu cannot be loaded the person gets a localized toast and the row closes", async (t) => { + const { value, calls } = host([]); + const ui = menuHarness(t, { autoSort: value, locale: "ru" }, brokenMenuModule); + await ui.openTools(); + assert.deepEqual(calls.error, ["Автосортировка не удалась"]); + assert.equal(ui.row("Инструменты").props["aria-expanded"], false); + assert.equal(ui.tools(), undefined); +}); + +test("the Tools submenu lists the criteria as radio rows and sorts, outlines and remembers the criterion", () => { + const { value, calls } = host([terminal("a", 3_000, 0), terminal("b", 3_000, 900), terminal("c", 5_000, 400)]); + const menu = toolsMenu(value); + assert.deepEqual(menu.radios.map((node) => node.props.children), ["By task", "By status", "By project", "Grid"]); + assert.ok(menu.radios.every((node) => node.props["aria-checked"] === false && !node.props.disabled)); + assert.equal(menu.row("Undo auto-sort"), undefined, "no undo before a sort"); + assert.doesNotMatch(menu.text, /needs at least 2 windows/); + + menu.row("Grid").props.onClick(); + assert.equal(menu.done(), 1); + assert.equal(calls.commit.length, 1); + const moved = calls.commit[0]; + assert.deepEqual([...moved.keys()].sort(), ["terminal:a", "terminal:b", "terminal:c"]); + assert.deepEqual(calls.mark.map((ids) => ids.sort()), [["terminal:a", "terminal:b", "terminal:c"]], "the moved windows are outlined"); + assert.equal(calls.state.length, 1); + assert.equal(calls.state[0].mode, "grid"); + assert.deepEqual(calls.state[0].undo.after, moved); + + // The next menu checks the criterion and offers undo while a window is still where the sort put it. + const after = host([...moved].map(([id, bounds]) => [id, bounds]), { state: calls.state[0] }); + const next = toolsMenu(after.value); + assert.deepEqual(next.radios.filter((node) => node.props["aria-checked"] === true).map((node) => node.props.children), ["Grid"]); + next.row("Undo auto-sort").props.onClick(); + // terminal:a already sat at the block's corner, so only the windows that actually moved go back. + assert.deepEqual(moved.get("terminal:a"), card(3_000, 0)); + assert.deepEqual(after.calls.commit[0], new Map([["terminal:b", card(3_000, 900)], ["terminal:c", card(5_000, 400)]])); + assert.deepEqual(after.calls.mark.map((ids) => ids.sort()), [["terminal:b", "terminal:c"]]); + assert.deepEqual(after.calls.state, [{ mode: "grid", undo: null }]); +}); + +test("a selection without two windows does not narrow the sort, and too few windows disable it with a hint", () => { + const windows = [terminal("a", 3_000, 0), terminal("b", 3_000, 900)]; + const notes = [["note:1", card(9_000, 0)], ["note:2", card(9_000, 900)]]; + const selectedNotes = host(windows, { layers: notes, selection: new Set(["note:1", "note:2"]) }); + const menu = toolsMenu(selectedNotes.value); + assert.ok(menu.radios.every((node) => !node.props.disabled), "two selected notes fall back to the default scope"); + menu.row("By task").props.onClick(); + assert.deepEqual([...selectedNotes.calls.commit[0].keys()].sort(), ["terminal:a", "terminal:b"]); + + const lonely = host([terminal("a", 3_000, 0)], { layers: notes }); + const ru = toolsMenu(lonely.value, "ru"); + assert.ok(ru.radios.every((node) => node.props.disabled === true)); + assert.match(ru.text, /нужно минимум 2 окна/); + ru.radios[0].props.onClick(); + assert.deepEqual(lonely.calls.state, [], "the criterion is kept only once a sort happened"); + assert.deepEqual(lonely.calls.commit, []); +}); + +test("a sort that throws reports a localized toast instead of leaving the menu half done", () => { + const { value, calls } = host([terminal("a", 3_000, 0), terminal("b", 3_000, 900)]); + value.commit = () => { throw new Error("boom"); }; + const oldError = console.error; + console.error = () => undefined; + try { + const menu = toolsMenu(value); + menu.row("Grid").props.onClick(); + assert.deepEqual(calls.error, ["Auto-sort failed"]); + assert.equal(menu.done(), 1); + assert.deepEqual(calls.state, [], "no criterion or undo step is kept for a failed sort"); + } finally { + console.error = oldError; + } +}); + +// Wiring that only the whole workspace shows; kept to the few facts a behaviour test cannot reach. +test("the workspace keeps auto-sort out of the startup bundle, outlines sorted windows without selecting them, and adds no undo shortcut", async () => { + const [canvas, menu] = await Promise.all([ + source("src/renderer/src/features/workspace/WorkspaceCanvas.tsx"), + source("src/renderer/src/features/workspace/CanvasContextMenu.tsx") + ]); + for (const code of [canvas, menu]) { + assert.doesNotMatch(code, /^import (?!type)[^;]*from "\.\/(canvasAutoSort|CanvasAutoSortMenu|canvasAutoSortScope)";/m); + } + assert.doesNotMatch(canvas, /setMarqueeSelection\(new Set\((plan|restore)/, "a sort never turns its windows into a drag group"); + assert.match(canvas, /groupSelected=\{marqueeSelection\.has\(terminalLayerId\(session\.id\)\) \|\| autoSortMarked\.has/); + assert.match(canvas, /element\?\.addEventListener\("pointerdown", clear, true\)/, "the next press clears the outline"); + assert.doesNotMatch(canvas, /key === "z"|KeyZ|"Mod\+Z"|ctrl\+z/i, "no global undo shortcut"); +}); + +test("auto-sort strings exist in every locale and new code uses tokens, vendored icons and no literal glyphs", async () => { + const [i18n, menu, tools, sorter, scope, styles, icons, readme, wrench, undo] = await Promise.all([ + source("src/renderer/src/lib/i18n.ts"), + source("src/renderer/src/features/workspace/CanvasContextMenu.tsx"), + source("src/renderer/src/features/workspace/CanvasAutoSortMenu.tsx"), + source("src/renderer/src/features/workspace/canvasAutoSort.ts"), + source("src/renderer/src/features/workspace/canvasAutoSortScope.ts"), + source("src/renderer/src/styles/app.css"), + source("src/renderer/src/components/UiIcon.tsx"), + source("src/renderer/src/assets/icons/lucide/README.md"), + source("src/renderer/src/assets/icons/lucide/wrench.svg"), + source("src/renderer/src/assets/icons/lucide/undo-2.svg") + ]); + const enStart = i18n.indexOf("const en: Record = {"); + const ru = i18n.slice(0, enStart); + const en = i18n.slice(enStart); + const keys = ["canvasMenuTools", "canvasAutoSort", "canvasAutoSortTask", "canvasAutoSortStatus", "canvasAutoSortProject", + "canvasAutoSortGrid", "canvasAutoSortUnavailable", "canvasAutoSortUndo", "canvasAutoSortFailed"]; + for (const key of keys) { + const ruValue = ru.match(new RegExp(`\\n ${key}: "([^"]+)"`))?.[1]; + const enValue = en.match(new RegExp(`\\n ${key}: "([^"]+)"`))?.[1]; + assert.ok(ruValue && enValue, `${key} is translated in ru and en`); + assert.match(ruValue, /[а-яё]/i, `${key} has a Russian value`); + assert.doesNotMatch(enValue, /[а-яё]/i, `${key} has an English value`); + } + + const toolsBlock = menu.slice(menu.indexOf('icon="wrench"'), menu.indexOf('{kind === "region" && (')); + for (const code of [toolsBlock, tools, sorter, scope]) { + assert.doesNotMatch(code, /#[\da-f]{3,8}\b|rgba?\(/i); + assert.doesNotMatch(code, /[›▸▶→↶↺×✓✔]/u, "icons come from UiIcon, not literal glyphs"); + } + const sortStyles = styles.split("\n").filter((line) => line.includes("auto-sorting") || line.includes("canvas-menu__check") + || line.includes("canvas-menu__row:disabled")); + assert.ok(sortStyles.length >= 4); + for (const line of sortStyles) assert.doesNotMatch(line, /#[\da-f]{3,8}\b|rgba?\(|\d+px/i, line); + assert.match(styles, /@media \(prefers-reduced-motion: reduce\) \{ \.workspace--auto-sorting [^}]*transition: none; \}/); + assert.doesNotMatch(styles, /\.workspace--auto-sorting[^{]*\.browser-card/, "the native Browser view never glides"); + + assert.match(icons, /import undoIcon from "\.\.\/assets\/icons\/lucide\/undo-2\.svg";/); + assert.match(icons, /import wrenchIcon from "\.\.\/assets\/icons\/lucide\/wrench\.svg";/); + assert.match(readme, /`wrench`, `undo-2`/); + for (const svg of [wrench, undo]) { + assert.match(svg, /^ { + const [en, ru, zh] = await Promise.all([ + source("docs/UI_CONTRACT.md"), + source("docs/UI_CONTRACT.ru.md"), + source("docs/UI_CONTRACT.zh-CN.md") + ]); + assert.match(en, /a \*\*Tools\*\* submenu[\s\S]*\*\*Auto-sort\*\*[\s\S]*\*\*Undo auto-sort\*\*/); + assert.match(ru, /\*\*Инструменты\*\*[\s\S]*\*\*Автосортировка\*\*[\s\S]*\*\*Отменить автосортировку\*\*/); + assert.match(zh, /\*\*Tools\*\*[\s\S]*\*\*Auto-sort\*\*[\s\S]*\*\*Undo auto-sort\*\*/); +}); diff --git a/tests/helpers/fake-react.mjs b/tests/helpers/fake-react.mjs index 77b20e4a..1dcf395c 100644 --- a/tests/helpers/fake-react.mjs +++ b/tests/helpers/fake-react.mjs @@ -65,8 +65,11 @@ export default { useState, useRef, useEffect, useLayoutEffect, useCallback, useM const root = fileURLToPath(new URL("../..", import.meta.url)); -/** Imports `exports` (comma-separated names) from a repository-relative module, plus the fake React controls. */ -export async function importWithFakeReact(modulePath, exports, { checkSnapshots = false } = {}) { +/** + * Imports `exports` (comma-separated names) from a repository-relative module, plus the fake React controls. + * Extra esbuild `plugins` can stand in for modules, e.g. to make a lazily imported one fail to load. + */ +export async function importWithFakeReact(modulePath, exports, { checkSnapshots = false, plugins = [] } = {}) { const { outputFiles } = await build({ stdin: { contents: `export { ${exports} } from "./${modulePath}"; export { __render, __flush, __pending, __unmount, __reset } from "react";`, @@ -86,7 +89,7 @@ export async function importWithFakeReact(modulePath, exports, { checkSnapshots builder.onResolve({ filter: /^react(\/jsx-runtime)?$/ }, () => ({ path: "react", namespace: "fake-react" })); builder.onLoad({ filter: /.*/, namespace: "fake-react" }, () => ({ contents: FAKE_REACT, loader: "js" })); } - }] + }, ...plugins] }); return import(`data:text/javascript;base64,${Buffer.from(outputFiles[0].contents).toString("base64")}`); } diff --git a/tests/settings-ui.test.mjs b/tests/settings-ui.test.mjs index c33d31b2..b2f71814 100644 --- a/tests/settings-ui.test.mjs +++ b/tests/settings-ui.test.mjs @@ -293,6 +293,8 @@ test("the redesigned menus use shared tokens, em geometry, and the configured UI const menuStyleEnd = styles.indexOf(".sticky-note-card", menuStyleStart); const menuStyles = styles.slice(menuStyleStart, menuStyleEnd); assert.doesNotMatch(menuStyles, /#[\da-f]{3,8}|rgba?\(/i); + assert.match(menuStyles, /\.canvas-menu__check \{[^}]*color: var\(--primary\)/); + assert.match(menuStyles, /\.canvas-menu__row:disabled \{/); assert.match(styles, /\.launcher-dock \{[^}]*font-size: calc\(13px \* var\(--ui-scale, 1\)\)/); assert.match(styles, /--card-header-height: calc\(54px \* var\(--ui-scale, 1\)\)/); assert.match(styles, /\.terminal-card__header \{[^}]*font-size: calc\(13px \* var\(--ui-scale, 1\)\)/); From 9bd907968bedd6e97057724c8d4bb1387a4f3e37 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:05:09 +0000 Subject: [PATCH 3/3] fix(canvas): keep the Tools menu readable near corner panels and zoomed out - A context menu opened near a corner panel (minimap, shortcut hints, Home/zoom controls) moves up until it clears it; it was drawn over the bottom of the Home/zoom bar. When moving up cannot clear every panel (a tall menu near the right edge at UI scale 1.25, between the minimap and the shortcut hints), it moves sideways beside the panel it hits, at its own height; with no room on either side it keeps its place. Menus stay above the corner panels. - A submenu (Tools, Launch agent) opens where it covers no corner panel either: when its usual side and direction would run over the shortcut hints, minimap or Home/zoom controls (in whichever corners Settings put them), it opens in the other direction or on the other side; at UI scale 1.25 near the right edge, Tools opened over the hints panel and left its clipped text showing beside it. - The chevrons of Launch agent and Tools point to the side their submenu opens (the vendored chevron, rotated), not down. - "Needs at least 2 windows" is a plain hint, not the uppercase group label. It lines up with the radio labels and is as muted as the disabled rows it explains, so it does not read like an active item. - Zoomed out (summary), the outline of the windows a sort moved grows with the summary scale, so it stays visible at overview zoom for its 4 s. The placement code (and the existing chooseCanvasSubmenuSide) moves from canvasContextRouting to canvasMenuPlacement, which only the lazily loaded menu imports, so it stays out of the startup bundle. --- .../src/components/CanvasMenuPrimitives.tsx | 5 +- .../features/workspace/CanvasAutoSortMenu.tsx | 2 +- .../features/workspace/CanvasContextMenu.tsx | 69 ++++++++--- .../workspace/canvasContextRouting.ts | 19 --- .../features/workspace/canvasMenuPlacement.ts | 113 ++++++++++++++++++ src/renderer/src/styles/app.css | 7 ++ tests/canvas-auto-sort.test.mjs | 12 +- tests/canvas-context-menu.test.mjs | 84 ++++++++++++- 8 files changed, 271 insertions(+), 40 deletions(-) create mode 100644 src/renderer/src/features/workspace/canvasMenuPlacement.ts diff --git a/src/renderer/src/components/CanvasMenuPrimitives.tsx b/src/renderer/src/components/CanvasMenuPrimitives.tsx index 1347093d..73f4ea98 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/features/workspace/CanvasAutoSortMenu.tsx b/src/renderer/src/features/workspace/CanvasAutoSortMenu.tsx index a0713674..a9234988 100644 --- a/src/renderer/src/features/workspace/CanvasAutoSortMenu.tsx +++ b/src/renderer/src/features/workspace/CanvasAutoSortMenu.tsx @@ -93,7 +93,7 @@ export function CanvasAutoSortMenu({ host, locale, className, onDone }: { onClick={() => run(() => runCanvasAutoSort(host, mode))} >{t(locale, LABELS[mode])} ))} - {!available && {t(locale, "canvasAutoSortUnavailable")}} + {!available &&

{t(locale, "canvasAutoSortUnavailable")}

}
{undoAvailable && ( <> diff --git a/src/renderer/src/features/workspace/CanvasContextMenu.tsx b/src/renderer/src/features/workspace/CanvasContextMenu.tsx index 33c2c711..bb346a90 100644 --- a/src/renderer/src/features/workspace/CanvasContextMenu.tsx +++ b/src/renderer/src/features/workspace/CanvasContextMenu.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import type { CanvasLauncherItemId, LocaleId, @@ -17,11 +17,8 @@ import { CanvasMenuRow, CanvasMenuSub } from "../../components/CanvasMenuPrimitives"; -import { - chooseCanvasSubmenuSide, - type CanvasContextMenuKind, - type CanvasSubmenuSide -} from "./canvasContextRouting"; +import type { CanvasContextMenuKind, CanvasSubmenuSide } from "./canvasContextRouting"; +import { canvasMenuPlacementClearOfPanels, chooseCanvasSubmenuPlacement, chooseCanvasSubmenuSide } from "./canvasMenuPlacement"; import type { CanvasAutoSortHost } from "./canvasAutoSortScope"; import { CANVAS_REGION_COLORS } from "./canvasRegions"; @@ -69,6 +66,13 @@ interface CanvasContextMenuProps { onClose(): void; } +/** The visible canvas corner panels (minimap, shortcut hints, Home/zoom controls), wherever Settings put them. */ +function cornerPanelRects(workspace: HTMLElement): DOMRect[] { + return [...workspace.querySelectorAll(".canvas-overlays .canvas-overlay-slot > *")] + .map((panel) => panel.getBoundingClientRect()) + .filter((panel) => panel.width > 0 && panel.height > 0); +} + export function CanvasContextMenu({ kind, position, @@ -119,6 +123,44 @@ export function CanvasContextMenu({ loadToolsMenu().then((loaded) => { if (live) setToolsMenu(() => loaded); }, () => undefined); return () => { live = false; }; }, [kind]); + // Before the first paint: keep the menu off the corner panels (up, or else sideways), and point the submenu chevrons + // where they open. + const [place, setPlace] = useState({ left: position.x, top: position.y }); + useLayoutEffect(() => { + const root = menu.current; + const workspace = root?.closest(".workspace"); + if (!root || !workspace) { + setPlace({ left: position.x, top: position.y }); + return; + } + const area = workspace.getBoundingClientRect(); + const rect = root.getBoundingClientRect(); + const placed = { left: area.left + position.x, right: area.left + position.x + rect.width, top: area.top + position.y, + bottom: area.top + position.y + rect.height }; + const panels = cornerPanelRects(workspace); + const fontSize = Number.parseFloat(window.getComputedStyle(root).fontSize) || 13; + const clear = canvasMenuPlacementClearOfPanels(placed, panels, + { minTop: area.top + .9 * fontSize, minLeft: area.left + .9 * fontSize, maxRight: area.right - .9 * fontSize }, .6 * fontSize); + setPlace({ left: clear.left - area.left, top: clear.top - area.top }); + }, [kind, position.x, position.y]); + // After the menu has its place: the submenus open toward the side with room. + useLayoutEffect(() => { + chooseSubmenuSide(); + }, [kind, place.left, place.top]); + // An open submenu, once measured, goes where it covers no corner panel: the other direction or side when needed. + useLayoutEffect(() => { + const submenu = menu.current?.querySelector(".canvas-menu__submenu"); + const anchor = submenu?.closest(".canvas-menu__submenu-anchor"); + const workspace = menu.current?.closest(".workspace"); + if (!submenu || !anchor || !workspace) return; + const fontSize = Number.parseFloat(window.getComputedStyle(menu.current!).fontSize) || 13; + const { width, height } = submenu.getBoundingClientRect(); + const placement = chooseCanvasSubmenuPlacement(anchor.getBoundingClientRect(), { width, height }, + workspace.getBoundingClientRect(), cornerPanelRects(workspace), + { gap: .55 * fontSize, offset: .45 * fontSize, clearance: .6 * fontSize }, toolsOpen); + setSubmenuSide(placement.side); + if (toolsOpen) setToolsUp(placement.up); + }, [launcherOpen, toolsOpen, ToolsMenu, place.left, place.top]); const chooseSubmenuSide = (): void => { const root = menu.current; @@ -139,21 +181,16 @@ export function CanvasContextMenu({ setLauncherOpen(false); return; } - chooseSubmenuSide(); setToolsOpen(false); setLauncherOpen(true); }; - const toggleTools = (event: React.MouseEvent): void => { + // Where an open submenu goes (side, up or down) is chosen once it is measured, in the layout effect above. + const toggleTools = (): void => { if (toolsOpen) { setToolsOpen(false); return; } - chooseSubmenuSide(); - const workspace = menu.current?.closest(".workspace"); - const fontSize = Number.parseFloat(window.getComputedStyle(event.currentTarget).fontSize) || 13; - setToolsUp(Boolean(workspace) - && event.currentTarget.getBoundingClientRect().top + 22 * fontSize > workspace!.getBoundingClientRect().bottom); setLauncherOpen(false); setToolsOpen(true); if (!loadedToolsMenu) { @@ -174,7 +211,7 @@ export function CanvasContextMenu({ data-interactive="true" role="menu" aria-label={t(locale, "canvasContextMenu")} - style={{ left: position.x, top: position.y }} + style={{ left: place.left, top: place.top }} onContextMenu={(event) => event.preventDefault()} onKeyDown={(event) => { if (event.key === "Escape") { @@ -219,7 +256,7 @@ export function CanvasContextMenu({
} + right={} role="menuitem" aria-haspopup="menu" aria-expanded={launcherOpen} @@ -264,7 +301,7 @@ export function CanvasContextMenu({
} + right={} role="menuitem" aria-haspopup="menu" aria-expanded={toolsOpen} diff --git a/src/renderer/src/features/workspace/canvasContextRouting.ts b/src/renderer/src/features/workspace/canvasContextRouting.ts index 4c4fcadd..e0aaf638 100644 --- a/src/renderer/src/features/workspace/canvasContextRouting.ts +++ b/src/renderer/src/features/workspace/canvasContextRouting.ts @@ -4,11 +4,6 @@ export type CanvasContextHit = "empty" | "region" | "note" | "material" | "nativ export type CanvasContextMenuKind = "empty" | "region" | "note" | "material"; export type CanvasSubmenuSide = "left" | "right"; -interface HorizontalBounds { - left: number; - right: number; -} - export function routeCanvasContextMenu( hit: CanvasContextHit, homeEditing: boolean @@ -28,17 +23,3 @@ export function clampCanvasMenuPosition( y: Math.max(inset, Math.min(pointer.y, Math.max(inset, viewport.height - menu.height - inset))) }; } - -export function chooseCanvasSubmenuSide( - anchor: HorizontalBounds, - viewport: HorizontalBounds, - submenuWidth: number, - gap: number -): CanvasSubmenuSide { - const required = submenuWidth + gap; - const rightSpace = viewport.right - anchor.right; - const leftSpace = anchor.left - viewport.left; - if (rightSpace >= required) return "right"; - if (leftSpace >= required) return "left"; - return rightSpace >= leftSpace ? "right" : "left"; -} diff --git a/src/renderer/src/features/workspace/canvasMenuPlacement.ts b/src/renderer/src/features/workspace/canvasMenuPlacement.ts new file mode 100644 index 00000000..6c17a0de --- /dev/null +++ b/src/renderer/src/features/workspace/canvasMenuPlacement.ts @@ -0,0 +1,113 @@ +import type { Size } from "../../../../shared/contracts"; +import type { CanvasSubmenuSide } from "./canvasContextRouting"; + +// Placement of the canvas context menu and its submenus around the corner panels. Only the lazily loaded menu uses it, +// so it stays out of the startup bundle. + +interface HorizontalBounds { + left: number; + right: number; +} + +export function chooseCanvasSubmenuSide( + anchor: HorizontalBounds, + viewport: HorizontalBounds, + submenuWidth: number, + gap: number +): CanvasSubmenuSide { + const required = submenuWidth + gap; + const rightSpace = viewport.right - anchor.right; + const leftSpace = anchor.left - viewport.left; + if (rightSpace >= required) return "right"; + if (leftSpace >= required) return "left"; + return rightSpace >= leftSpace ? "right" : "left"; +} + +interface Box { + left: number; + top: number; + right: number; + bottom: number; +} + +/** + * The top for a context menu so it does not cover a corner panel (minimap, shortcut hints, Home/zoom controls): a menu + * opened near a panel moves up until it clears it, by `gap`, while it stays at or below `minTop`. When that is not + * possible the menu keeps its place (it is drawn above the panels anyway). + */ +export function canvasMenuTopClearOfPanels(menu: Box, panels: readonly Box[], minTop: number, gap: number): number { + const height = menu.bottom - menu.top; + const overlaps = (top: number, panel: Box): boolean => menu.left < panel.right && menu.right > panel.left + && top < panel.bottom + gap && top + height > panel.top - gap; + let top = menu.top; + for (let step = 0; step < panels.length; step++) { + const hit = panels.find((panel) => overlaps(top, panel)); + if (!hit) return top; + top = hit.top - gap - height; + if (top < minTop) return menu.top; + } + return panels.some((panel) => overlaps(top, panel)) ? menu.top : top; +} + +/** + * Where a context menu goes so it covers no corner panel, at any UI scale: first up (canvasMenuTopClearOfPanels); + * when that cannot clear every panel, sideways at its own height, to the side of the panel it hits with room for it + * within `minLeft`..`maxRight`. When neither works it keeps its place (drawn above the panels anyway). + */ +export function canvasMenuPlacementClearOfPanels( + menu: Box, panels: readonly Box[], bounds: { minTop: number; minLeft: number; maxRight: number }, gap: number +): { left: number; top: number } { + const width = menu.right - menu.left; + const height = menu.bottom - menu.top; + const hits = (left: number, top: number): Box | undefined => panels.find((panel) => left < panel.right + && left + width > panel.left && top < panel.bottom + gap && top + height > panel.top - gap); + const top = canvasMenuTopClearOfPanels(menu, panels, bounds.minTop, gap); + if (!hits(menu.left, top)) return { left: menu.left, top }; + // Beside the panel it hits, nearest to where it was opened first, never back to a place already tried. + const tried = new Set([menu.left]); + let left = menu.left; + for (let step = 0; step <= panels.length; step++) { + const hit = hits(left, menu.top); + if (!hit) return { left, top: menu.top }; + const next = [hit.left - gap - width, hit.right + gap] + .filter((candidate) => !tried.has(candidate) && candidate >= bounds.minLeft && candidate + width <= bounds.maxRight) + .sort((a, b) => Math.abs(a - menu.left) - Math.abs(b - menu.left))[0]; + if (next === undefined) break; + tried.add(next); + left = next; + } + return { left: menu.left, top: menu.top }; +} + +/** + * Where a submenu opens beside its row (`anchor`): on the side with room (chooseCanvasSubmenuSide), downward unless it + * runs past the canvas bottom (only when `allowUp`). When that place covers a corner panel (minimap, shortcut hints, + * Home/zoom controls; their corners are set in Settings), the first of the other sides and directions that stays + * inside the canvas and clear of every panel by `metrics.clearance`. When none is clear it keeps the preferred place + * (drawn above the panels anyway). `metrics.gap` and `metrics.offset` are the submenu's CSS offsets from the row. + */ +export function chooseCanvasSubmenuPlacement( + anchor: Box, + size: Size, + area: Box, + panels: readonly Box[], + metrics: { gap: number; offset: number; clearance: number }, + allowUp: boolean +): { side: CanvasSubmenuSide; up: boolean } { + const { width, height } = size; + const { gap, offset, clearance } = metrics; + const box = (side: CanvasSubmenuSide, up: boolean): Box => { + const left = side === "right" ? anchor.right + gap : anchor.left - gap - width; + const top = up ? anchor.bottom + offset - height : anchor.top - offset; + return { left, top, right: left + width, bottom: top + height }; + }; + const fits = (b: Box): boolean => b.left >= area.left && b.right <= area.right && b.top >= area.top + && b.bottom <= area.bottom && !panels.some((panel) => b.left < panel.right + clearance + && b.right > panel.left - clearance && b.top < panel.bottom + clearance && b.bottom > panel.top - clearance); + const side = chooseCanvasSubmenuSide(anchor, area, width, gap); + const up = allowUp && box(side, false).bottom > area.bottom; + const other: CanvasSubmenuSide = side === "right" ? "left" : "right"; + const candidates = [{ side, up }, { side, up: !up }, { side: other, up }, { side: other, up: !up }] + .filter((candidate) => allowUp || !candidate.up); + return candidates.find((candidate) => fits(box(candidate.side, candidate.up))) ?? { side, up }; +} diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index 7a31e809..9bf970e9 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -398,6 +398,11 @@ button { border: 0; } .canvas-menu__kbd { padding: .2em .55em; border-radius: .4em; color: var(--text-dark); background: var(--secondary); font: 400 .78em/1.2 var(--font-mono); white-space: nowrap; } .canvas-menu__sub { color: var(--text-muted-on-dark); font-size: .92em; font-weight: 400; white-space: nowrap; } .canvas-menu__chevron { display: inline-flex; color: var(--text-muted-on-dark); } +/* A submenu that opens to the side: the same chevron, turned toward it. */ +.canvas-menu__chevron--right { transform: rotate(-90deg); } +.canvas-menu__chevron--left { transform: rotate(90deg); } +/* The hint explains the disabled rows above it: aligned with their text (1.55em of the row font) and as quiet as they are. */ +.canvas-menu__hint { margin: 0; padding: .3em .8em .45em calc(1.55em / .92); color: var(--text); opacity: .5; font-size: .92em; line-height: 1.35; } .canvas-menu__check { display: inline-flex; color: var(--primary); } .canvas-menu__row:disabled { cursor: default; opacity: .5; } .canvas-menu__row:disabled:hover { background: transparent; } @@ -683,6 +688,8 @@ button { border: 0; } /* Auto-sort: sorted windows glide to their new place, without motion when reduced motion is requested. The Browser card jumps: its native page view is placed from the card's measured rect and would stay at the start of a glide. */ .workspace--auto-sorting :is(.terminal-card, .plugin-canvas-card) { transition: transform .24s ease, box-shadow .15s ease; } +/* Zoomed out (summary), the outline of the windows a sort moved stays as thick on screen as at 50 % zoom. */ +.workspace--auto-sorting :is(.terminal-card--summary.terminal-card--selected, .plugin-canvas-card--summary.plugin-canvas-card--selected, .browser-card--summary.browser-card--selected) { box-shadow: 0 0 0 calc(.375rem * var(--summary-scale, 1)) var(--primary), var(--shadow-lg); } @media (prefers-reduced-motion: reduce) { .workspace--auto-sorting :is(.terminal-card, .plugin-canvas-card) { transition: none; } } .browser-card:focus-within, .browser-card--selected { box-shadow: 0 0 0 3px var(--primary), var(--shadow-lg); } .browser-card__header { height: var(--card-header-height); padding: 0 calc(11px * var(--ui-scale, 1)) 0 calc(15px * var(--ui-scale, 1)); display: flex; align-items: center; justify-content: space-between; gap: calc(12px * var(--ui-scale, 1)); color: white; background: var(--surface-soft); cursor: move; font-size: calc(13px * var(--ui-scale, 1)); touch-action: none; } diff --git a/tests/canvas-auto-sort.test.mjs b/tests/canvas-auto-sort.test.mjs index d38065a9..4830f583 100644 --- a/tests/canvas-auto-sort.test.mjs +++ b/tests/canvas-auto-sort.test.mjs @@ -510,7 +510,17 @@ test("auto-sort strings exist in every locale and new code uses tokens, vendored assert.ok(sortStyles.length >= 4); for (const line of sortStyles) assert.doesNotMatch(line, /#[\da-f]{3,8}\b|rgba?\(|\d+px/i, line); assert.match(styles, /@media \(prefers-reduced-motion: reduce\) \{ \.workspace--auto-sorting [^}]*transition: none; \}/); - assert.doesNotMatch(styles, /\.workspace--auto-sorting[^{]*\.browser-card/, "the native Browser view never glides"); + assert.doesNotMatch(styles, /\.workspace--auto-sorting :is\(\.terminal-card, \.plugin-canvas-card\) \{[^}]*\.browser-card/, "the native Browser view never glides"); + // Zoomed out, the outline of the moved windows keeps its on-screen thickness: it grows with the summary scale. + assert.match(styles, /\.workspace--auto-sorting :is\(\.terminal-card--summary\.terminal-card--selected, \.plugin-canvas-card--summary\.plugin-canvas-card--selected, \.browser-card--summary\.browser-card--selected\) \{ box-shadow: 0 0 0 calc\([^)]*var\(--summary-scale, 1\)\) var\(--primary\)/); + // "Needs at least 2 windows" is a plain muted hint, not the uppercase group label. + assert.match(tools, /\{!available &&

\{t\(locale, "canvasAutoSortUnavailable"\)\}<\/p>\}/); + const hint = styles.match(/\.canvas-menu__hint \{[^}]*\}/)[0]; + const disabledRow = styles.match(/\.canvas-menu__row:disabled \{[^}]*\}/)[0]; + assert.match(hint, /padding: [^;]*calc\(1\.55em \/ \.92\)/, "the hint's text lines up with the indented rows despite its smaller font"); + assert.match(hint, /color: var\(--text\); opacity: \.5;/, "as quiet as the disabled rows it explains"); + assert.match(disabledRow, /opacity: \.5/); + assert.doesNotMatch(styles.match(/\.canvas-menu__hint \{[^}]*\}/)[0], /uppercase|font-mono/); assert.match(icons, /import undoIcon from "\.\.\/assets\/icons\/lucide\/undo-2\.svg";/); assert.match(icons, /import wrenchIcon from "\.\.\/assets\/icons\/lucide\/wrench\.svg";/); diff --git a/tests/canvas-context-menu.test.mjs b/tests/canvas-context-menu.test.mjs index f076506a..beb5b38d 100644 --- a/tests/canvas-context-menu.test.mjs +++ b/tests/canvas-context-menu.test.mjs @@ -2,10 +2,15 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; import { - chooseCanvasSubmenuSide, clampCanvasMenuPosition, routeCanvasContextMenu } from "../src/renderer/src/features/workspace/canvasContextRouting.ts"; +import { + canvasMenuPlacementClearOfPanels, + canvasMenuTopClearOfPanels, + chooseCanvasSubmenuPlacement, + chooseCanvasSubmenuSide +} from "../src/renderer/src/features/workspace/canvasMenuPlacement.ts"; const contextMenuComponentPath = new URL( "../src/renderer/src/features/workspace/CanvasContextMenu.tsx", @@ -46,3 +51,80 @@ test("context menus stay inside the canvas viewport", () => { { x: 12, y: 12 } ); }); + +test("a context menu opened near a corner panel moves up off it, and its submenu chevrons point where they open", async () => { + // Home/zoom controls in the bottom-left corner; the menu was clamped to the bottom of a 856 px canvas. + const controls = { left: 18, top: 838, right: 170, bottom: 882 }; + const hints = { left: 1100, top: 615, right: 1420, bottom: 882 }; + const menu = { left: 60, top: 531, right: 272, bottom: 856 }; + assert.equal(canvasMenuTopClearOfPanels(menu, [controls, hints], 12, 8), 838 - 8 - 325); + assert.equal(canvasMenuTopClearOfPanels({ ...menu, left: 300, right: 512 }, [controls, hints], 12, 8), 531, "clear of every panel: unchanged"); + assert.equal(canvasMenuTopClearOfPanels(menu, [controls], 600, 8), 531, "no room above: it keeps its place (drawn above the panel)"); + // Up off one panel and then above a second one where it fits; when it cannot clear both it keeps its place. + assert.equal(canvasMenuTopClearOfPanels(menu, [controls, { left: 18, top: 480, right: 170, bottom: 520 }], 12, 8), 480 - 8 - 325); + assert.equal(canvasMenuTopClearOfPanels(menu, [controls, { left: 18, top: 300, right: 170, bottom: 520 }], 12, 8), 531); + + const component = await readFile(contextMenuComponentPath, "utf8"); + assert.match(component, /useLayoutEffect\(\(\) => \{[\s\S]*canvasMenuPlacementClearOfPanels\([\s\S]*\}, \[kind, position\.x, position\.y\]\);/); + assert.match(component, /useLayoutEffect\(\(\) => \{\n chooseSubmenuSide\(\);\n \}, \[kind, place\.left, place\.top\]\);/); + assert.match(component, /style=\{\{ left: place\.left, top: place\.top \}\}/); + assert.equal(component.match(/right=\{\}/g)?.length, 2, "Launch agent and Tools"); + const styles = await readFile(new URL("../src/renderer/src/styles/app.css", import.meta.url), "utf8"); + assert.match(styles, /\.canvas-menu__chevron--right \{ transform: rotate\(-90deg\); \}/); + assert.match(styles, /\.canvas-menu__chevron--left \{ transform: rotate\(90deg\); \}/); + // Menus (and their submenus, inside them) are drawn above the passive corner panels. + const z = (selector) => Number(styles.match(new RegExp(`\\n${selector.replace(".", "\\.")} \\{[^}]*z-index: (\\d+)`))?.[1]); + assert.ok(z(".canvas-menu") > z(".canvas-overlays")); +}); + +test("at a large UI scale a menu that cannot move up off the corner panels moves sideways instead", () => { + // 1.25: minimap top right, shortcut hints bottom right; the menu is too tall to fit between them. + const minimap = { left: 1232, top: 62, right: 1422, bottom: 184 }; + const hints = { left: 1100, top: 615, right: 1420, bottom: 882 }; + const menu = { left: 1055, top: 365, right: 1365, bottom: 845 }; + const bounds = { minTop: 12, minLeft: 12, maxRight: 1428 }; + assert.equal(canvasMenuTopClearOfPanels(menu, [minimap, hints], 12, 8), 365, "up is not possible"); + assert.deepEqual(canvasMenuPlacementClearOfPanels(menu, [minimap, hints], bounds, 8), { left: 1100 - 8 - 310, top: 365 }); + // Up still comes first when it works, and a menu clear of every panel stays put. + const low = { left: 60, top: 531, right: 272, bottom: 856 }; + const controls = { left: 18, top: 838, right: 170, bottom: 882 }; + assert.deepEqual(canvasMenuPlacementClearOfPanels(low, [controls, hints], bounds, 8), { left: 60, top: 838 - 8 - 325 }); + assert.deepEqual(canvasMenuPlacementClearOfPanels({ ...low, left: 300, right: 512 }, [controls, hints], bounds, 8), { left: 300, top: 531 }); + // No room on either side: it keeps its place. + assert.deepEqual(canvasMenuPlacementClearOfPanels(menu, [minimap, { ...hints, left: 20 }], bounds, 8), { left: 1055, top: 365 }); +}); + +test("a submenu opens where it covers no corner panel, wherever Settings put the panels", async () => { + // UI scale 1.25 (16.25 px font) in a 1440 x 900 window: the menu was opened near the right edge, its Tools row is + // low, and the shortcut hints sit in the bottom-right corner, the minimap top right, Home/zoom bottom left. + const area = { left: 0, top: 44, right: 1440, bottom: 900 }; + const hints = { left: 1035, top: 615, right: 1422, bottom: 882 }; + const minimap = { left: 1232, top: 62, right: 1422, bottom: 184 }; + const controls = { left: 18, top: 840, right: 168, bottom: 882 }; + const tools = { left: 722, top: 722, right: 1014, bottom: 770 }; + const size = { width: 309, height: 263 }; + const metrics = { gap: 9, offset: 7, clearance: 10 }; + // Room on the right, so the side rule alone opened it there, upward, over the hints panel. + assert.equal(chooseCanvasSubmenuSide(tools, area, size.width, metrics.gap), "right"); + assert.deepEqual(chooseCanvasSubmenuPlacement(tools, size, area, [hints, minimap, controls], metrics, true), + { side: "left", up: true }); + // Without panels in the way it keeps the side with room and goes up only when down runs off the canvas. + assert.deepEqual(chooseCanvasSubmenuPlacement(tools, size, area, [], metrics, true), { side: "right", up: true }); + assert.deepEqual(chooseCanvasSubmenuPlacement({ ...tools, top: 300, bottom: 348 }, size, area, [hints], metrics, true), + { side: "right", up: false }); + // Hints moved to the bottom-left corner: the right side is clear again. + const hintsLeft = { left: 18, top: 615, right: 405, bottom: 882 }; + assert.deepEqual(chooseCanvasSubmenuPlacement(tools, size, area, [hintsLeft, minimap], metrics, true), + { side: "right", up: true }); + // Up blocked by the panel but down fits: it opens down on the same side. + const tall = { left: 1035, top: 120, right: 1422, bottom: 420 }; + assert.deepEqual(chooseCanvasSubmenuPlacement({ ...tools, top: 380, bottom: 428 }, size, area, [tall], metrics, true), + { side: "left", up: false }); + // The launcher submenu never opens up; with no clear place at all the preferred place stays. + assert.deepEqual(chooseCanvasSubmenuPlacement(tools, size, area, [hints], metrics, false), { side: "right", up: false }); + const everywhere = { left: 0, top: 44, right: 1440, bottom: 900 }; + assert.deepEqual(chooseCanvasSubmenuPlacement(tools, size, area, [everywhere], metrics, true), { side: "right", up: true }); + + const component = await readFile(contextMenuComponentPath, "utf8"); + assert.match(component, /chooseCanvasSubmenuPlacement\(anchor\.getBoundingClientRect\(\),[\s\S]*cornerPanelRects\(workspace\)[\s\S]*toolsOpen\);\n setSubmenuSide\(placement\.side\);\n if \(toolsOpen\) setToolsUp\(placement\.up\);\n \}, \[launcherOpen, toolsOpen, ToolsMenu, place\.left, place\.top\]\);/); +});