From cf04ad2ba512bc65d7b500bc1c78bb8db882be3a Mon Sep 17 00:00:00 2001 From: Teo | Nexcore Date: Thu, 24 Sep 2026 19:20:49 +0300 Subject: [PATCH] fix(canvas): place new terminals in free slots near Home --- src/renderer/src/App.tsx | 56 ++++-- .../features/workspace/sessionPlacement.ts | 169 ++++++++++++++++++ tests/session-placement.test.mjs | 168 +++++++++++++++++ 3 files changed, 375 insertions(+), 18 deletions(-) create mode 100644 src/renderer/src/features/workspace/sessionPlacement.ts create mode 100644 tests/session-placement.test.mjs diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 37589a88..91a5db2b 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -62,6 +62,7 @@ import { } from "./lib/shortcuts"; import { homeGridPixelSize, homeLayoutFitsGrid, placeHomeWidget } from "./features/home/homeLayout"; import { boundsInsideRegion, translateBounds } from "./features/workspace/canvasRegions"; +import { DEFAULT_SESSION_SIZE, findNearHomeSessionPosition } from "./features/workspace/sessionPlacement"; interface HomeEditDraft { homeGridSize: HomeGridSize; @@ -185,6 +186,9 @@ function contrastRatio(left: number, right: number): number { export function App(): React.JSX.Element { const [settings, setSettings] = useState(FALLBACK_SETTINGS); const [sessions, setSessions] = useState([]); + const sessionsRef = useRef(sessions); + sessionsRef.current = sessions; + const pendingSessionPlacements = useRef([]); const [limits, setLimits] = useState(null); const [limitsLoadState, setLimitsLoadState] = useState("loading"); const [limitsRevision, setLimitsRevision] = useState(0); @@ -364,17 +368,41 @@ export function App(): React.JSX.Element { requestedCenter?: Point, role: LaunchRole = "agent" ): Promise => { + const currentSettings = settingsRef.current; const position = requestedCenter - ? centeredWindowPosition(requestedCenter, { width: 700, height: 430 }) - : nextSessionPosition(sessions.length, settings.homeGridSize); - const session = await window.canvasTTY.terminal.create({ provider, profile, cwd, position, role }); - setSessions((current) => upsertSnapshot(current, session)); - setActiveSessionId(session.id); - await saveSettings({ lastDirectory: cwd }); - isHomeCamera.current = false; - setCamera(focusCamera(position, session.size)); - return session; - }, [sessions.length, saveSettings, settings.homeGridSize]); + ? centeredWindowPosition(requestedCenter, DEFAULT_SESSION_SIZE) + : findNearHomeSessionPosition( + { position: { x: 0, y: 0 }, size: homeGridPixelSize(currentSettings.homeGridSize) }, + [ + ...sessionsRef.current, + ...currentSettings.pluginCanvas, + ...currentSettings.stickyNotes, + ...(currentSettings.browserCanvas ? [currentSettings.browserCanvas] : []), + ...pendingSessionPlacements.current + ], + DEFAULT_SESSION_SIZE + ); + // Reserve the slot until the async create finishes, so fast parallel launches + // cannot both choose the same free position before React renders either card. + const reservation: SessionBounds | null = requestedCenter + ? null + : { position, size: DEFAULT_SESSION_SIZE }; + if (reservation) pendingSessionPlacements.current.push(reservation); + try { + const session = await window.canvasTTY.terminal.create({ provider, profile, cwd, position, role }); + sessionsRef.current = upsertSnapshot(sessionsRef.current, session); + setSessions((current) => upsertSnapshot(current, session)); + setActiveSessionId(session.id); + await saveSettings({ lastDirectory: cwd }); + isHomeCamera.current = false; + setCamera(focusCamera(position, session.size)); + return session; + } finally { + if (reservation) { + pendingSessionPlacements.current = pendingSessionPlacements.current.filter((item) => item !== reservation); + } + } + }, [saveSettings]); const openTerminal = useCallback(async (position?: Point): Promise => { try { @@ -1164,14 +1192,6 @@ export function App(): React.JSX.Element { ); } -function nextSessionPosition(index: number, homeGridSize: HomeGridSize): Point { - const homeSize = homeGridPixelSize(homeGridSize); - return { - x: homeSize.width + 160 + (index % 2) * 760, - y: Math.floor(index / 2) * 500 + 20 - }; -} - function centeredWindowPosition(point: Point, size: { width: number; height: number }): Point { return { x: point.x - size.width / 2, diff --git a/src/renderer/src/features/workspace/sessionPlacement.ts b/src/renderer/src/features/workspace/sessionPlacement.ts new file mode 100644 index 00000000..5ad6250e --- /dev/null +++ b/src/renderer/src/features/workspace/sessionPlacement.ts @@ -0,0 +1,169 @@ +import type { Point, SessionBounds, Size } from "../../../../shared/contracts"; + +export interface SessionPlacementOptions { + /** Gap between placed cards. Defaults to 24px. */ + gap?: number; + /** Margin separating placed cards from Home bounds. Defaults to 40px. */ + margin?: number; + /** Maximum number of candidate positions to test before fallback. Defaults to 2000. */ + maxCandidates?: number; +} + +export const DEFAULT_SESSION_SIZE: Size = { width: 700, height: 430 }; +export const DEFAULT_SESSION_GAP = 24; +export const DEFAULT_SESSION_MARGIN = 40; + +/** + * Checks whether two bounds intersect, treating touch/overlap within gap as intersection. + */ +export function boundsOverlapOrTouch( + a: SessionBounds, + b: SessionBounds, + minGap = 0 +): boolean { + return ( + a.position.x < b.position.x + b.size.width + minGap && + a.position.x + a.size.width + minGap > b.position.x && + a.position.y < b.position.y + b.size.height + minGap && + a.position.y + a.size.height + minGap > b.position.y + ); +} + +/** + * Calculates Euclidean distance between the center of candidate bounds and the center of Home. + */ +function distanceToHomeCenter( + bounds: SessionBounds, + homeCenter: Point +): number { + const cx = bounds.position.x + bounds.size.width / 2; + const cy = bounds.position.y + bounds.size.height / 2; + return Math.hypot(cx - homeCenter.x, cy - homeCenter.y); +} + +/** + * Generates an ordered sequence of discrete grid candidate positions near Home. + * + * Design: + * - Starts at the primary anchor to the right (East) of Home, aligned with Home's top. + * X_base = home.x + home.width + margin + * Y_base = home.y + * - Grid steps are (cardWidth + gap, cardHeight + gap). + * - Searches in every direction around Home after the preferred first position. + * This keeps a large collection compact instead of growing endlessly down or right. + * - Candidates are sorted deterministically by distance from Home's visual center, + * with row-major / col-major tie-breaking. + * - Any freed positions (gaps created by closing/moving a card) are visited early in the sequence and reused. + * - No candidate intersects Home or any occupied bounds. + */ +export function generateCandidatePositions( + homeBounds: SessionBounds, + size: Size, + options: SessionPlacementOptions = {} +): Point[] { + const gap = options.gap ?? DEFAULT_SESSION_GAP; + const margin = options.margin ?? DEFAULT_SESSION_MARGIN; + const maxCandidates = options.maxCandidates ?? 2000; + + const stepX = size.width + gap; + const stepY = size.height + gap; + + // Primary anchor: East of Home + const baseX = homeBounds.position.x + homeBounds.size.width + margin; + const baseY = homeBounds.position.y; + + const homeCenter: Point = { + x: homeBounds.position.x + homeBounds.size.width / 2, + y: homeBounds.position.y + homeBounds.size.height / 2 + }; + + interface CandidateEntry { + point: Point; + dist: number; + col: number; + row: number; + } + + const entries: CandidateEntry[] = []; + + // The first slot stays immediately east of Home. After that, search the + // surrounding lattice; filtering below excludes cells overlapping Home. + const maxExtent = Math.max(6, Math.ceil(Math.sqrt(maxCandidates) / 2) + 4); + for (let col = -maxExtent; col <= maxExtent; col += 1) { + for (let row = -maxExtent; row <= maxExtent; row += 1) { + if (col === 0 && row === 0) continue; + const point: Point = { + x: baseX + col * stepX, + y: baseY + row * stepY + }; + const bounds: SessionBounds = { position: point, size }; + const dist = distanceToHomeCenter(bounds, homeCenter); + entries.push({ point, dist, col, row }); + } + } + + entries.sort((a, b) => { + if (Math.abs(a.dist - b.dist) > 0.001) { + return a.dist - b.dist; + } + if (a.row !== b.row) { + return a.row - b.row; + } + return a.col - b.col; + }); + + return [{ x: baseX, y: baseY }, ...entries.slice(0, maxCandidates - 1).map((e) => e.point)]; +} + +/** + * Finds a deterministic, non-overlapping free position for a new terminal session near Home. + * + * @param homeBounds Bounding box of Home. + * @param occupied Array of occupied card/session bounds currently on canvas. + * @param size Size of the card to place (defaults to 700x430). + * @param options Placement configuration (gap, margin). + * @returns The chosen Point (x, y) for the new session. + */ +export function findNearHomeSessionPosition( + homeBounds: SessionBounds, + occupied: readonly SessionBounds[], + size: Size = DEFAULT_SESSION_SIZE, + options: SessionPlacementOptions = {} +): Point { + const gap = options.gap ?? DEFAULT_SESSION_GAP; + const candidates = generateCandidatePositions(homeBounds, size, options); + + for (const candidate of candidates) { + const candidateBounds: SessionBounds = { position: candidate, size }; + + // Must not overlap Home + if (boundsOverlapOrTouch(candidateBounds, homeBounds, 0)) { + continue; + } + + // Must not overlap any occupied bounds + let collision = false; + for (const card of occupied) { + if (boundsOverlapOrTouch(candidateBounds, card, gap)) { + collision = true; + break; + } + } + + if (!collision) { + return candidate; + } + } + + // Fallback if all discrete candidates collided (extremely dense/large canvas): + // Place safely to the right of all known occupied bounds and Home. + let maxRight = homeBounds.position.x + homeBounds.size.width; + for (const card of occupied) { + maxRight = Math.max(maxRight, card.position.x + card.size.width); + } + + return { + x: maxRight + (options.margin ?? DEFAULT_SESSION_MARGIN), + y: homeBounds.position.y + }; +} diff --git a/tests/session-placement.test.mjs b/tests/session-placement.test.mjs new file mode 100644 index 00000000..949872bc --- /dev/null +++ b/tests/session-placement.test.mjs @@ -0,0 +1,168 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + boundsOverlapOrTouch, + DEFAULT_SESSION_GAP, + DEFAULT_SESSION_MARGIN, + DEFAULT_SESSION_SIZE, + findNearHomeSessionPosition, + generateCandidatePositions +} from "../src/renderer/src/features/workspace/sessionPlacement.ts"; + +const HOME_BOUNDS = { + position: { x: 0, y: 0 }, + size: { width: 1000, height: 700 } +}; + +const CARD_SIZE = { width: 700, height: 430 }; + +test("finds initial position to the right of Home with margin", () => { + const pos = findNearHomeSessionPosition(HOME_BOUNDS, [], CARD_SIZE); + assert.equal(pos.x, HOME_BOUNDS.position.x + HOME_BOUNDS.size.width + DEFAULT_SESSION_MARGIN); + assert.equal(pos.y, HOME_BOUNDS.position.y); +}); + +test("is deterministic: same inputs always yield identical position", () => { + const occupied = [ + { position: { x: 1040, y: 0 }, size: CARD_SIZE } + ]; + const pos1 = findNearHomeSessionPosition(HOME_BOUNDS, occupied, CARD_SIZE); + const pos2 = findNearHomeSessionPosition(HOME_BOUNDS, occupied, CARD_SIZE); + assert.deepEqual(pos1, pos2); +}); + +test("does not intersect Home or occupied bounds", () => { + const occupied = []; + for (let i = 0; i < 10; i++) { + const nextPos = findNearHomeSessionPosition(HOME_BOUNDS, occupied, CARD_SIZE); + const candidateBounds = { position: nextPos, size: CARD_SIZE }; + + // Check no overlap with Home + assert.equal( + boundsOverlapOrTouch(candidateBounds, HOME_BOUNDS, 0), + false, + `Card ${i} overlaps with Home` + ); + + // Check no overlap with existing occupied cards + for (let j = 0; j < occupied.length; j++) { + assert.equal( + boundsOverlapOrTouch(candidateBounds, occupied[j], DEFAULT_SESSION_GAP), + false, + `Card ${i} overlaps with card ${j}` + ); + } + + occupied.push(candidateBounds); + } +}); + +test("reuses freed positions when cards are closed or moved", () => { + const cards = []; + for (let i = 0; i < 5; i++) { + const pos = findNearHomeSessionPosition(HOME_BOUNDS, cards, CARD_SIZE); + cards.push({ position: pos, size: CARD_SIZE }); + } + + // Remove the second card (index 1), leaving a hole + const freedCard = cards[1]; + const occupiedWithHole = [cards[0], cards[2], cards[3], cards[4]]; + + // The next placement should fill the freed hole + const reusedPos = findNearHomeSessionPosition(HOME_BOUNDS, occupiedWithHole, CARD_SIZE); + assert.deepEqual(reusedPos, freedCard.position); +}); + +test("allocates 40+ cards in compact 2D growth rather than a strip endlessly downward", () => { + const occupied = []; + const cardCount = 45; + + for (let i = 0; i < cardCount; i++) { + const pos = findNearHomeSessionPosition(HOME_BOUNDS, occupied, CARD_SIZE); + occupied.push({ position: pos, size: CARD_SIZE }); + } + + assert.equal(occupied.length, cardCount); + + // Compute bounding box of all placed cards + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + + const uniqueX = new Set(); + const uniqueY = new Set(); + + for (const card of occupied) { + minX = Math.min(minX, card.position.x); + maxX = Math.max(maxX, card.position.x + card.size.width); + minY = Math.min(minY, card.position.y); + maxY = Math.max(maxY, card.position.y + card.size.height); + uniqueX.add(card.position.x); + uniqueY.add(card.position.y); + } + + const clusterWidth = maxX - minX; + const clusterHeight = maxY - minY; + const aspectRatio = clusterWidth / clusterHeight; + + // With a 1D downward strip (e.g. 2 columns), 45 cards would be ~23 rows tall: + // clusterWidth ~ 1400, clusterHeight ~ 10,000, aspectRatio ~ 0.14! + // For compact 2D growth: + // There should be multiple columns (at least 4) and multiple rows (at least 4). + assert.ok( + uniqueX.size >= 4, + `Expected at least 4 columns for compact 2D growth, got ${uniqueX.size}` + ); + assert.ok( + uniqueY.size >= 4, + `Expected at least 4 rows for compact 2D growth, got ${uniqueY.size}` + ); + + // Aspect ratio should be well-proportioned (e.g. between 0.6 and 2.5), not an endless strip (<0.2) + assert.ok( + aspectRatio >= 0.5 && aspectRatio <= 3.0, + `Expected 2D aspect ratio between 0.5 and 3.0, got ${aspectRatio.toFixed(2)}` + ); + assert.ok(maxY < 3000 && minY > -3000, `45 cards drifted too far vertically: ${minY}..${maxY}`); +}); + +test("respects different Home sizes without pushing the first card far away", () => { + for (const size of [{ width: 650, height: 520 }, { width: 1400, height: 900 }]) { + const home = { position: { x: -200, y: 100 }, size }; + const position = findNearHomeSessionPosition(home, []); + assert.deepEqual(position, { x: home.position.x + size.width + DEFAULT_SESSION_MARGIN, y: home.position.y }); + assert.equal(boundsOverlapOrTouch({ position, size: DEFAULT_SESSION_SIZE }, home), false); + } +}); + +test("handles custom size, gap, and margin options", () => { + const customSize = { width: 500, height: 300 }; + const customGap = 15; + const customMargin = 50; + + const pos = findNearHomeSessionPosition(HOME_BOUNDS, [], customSize, { + gap: customGap, + margin: customMargin + }); + + assert.equal(pos.x, HOME_BOUNDS.position.x + HOME_BOUNDS.size.width + customMargin); + assert.equal(pos.y, HOME_BOUNDS.position.y); +}); + +test("is bounded and robust with arbitrary occupied cards", () => { + // Random or arbitrary scattered existing cards + const existingCards = [ + { position: { x: 1040, y: 0 }, size: { width: 800, height: 500 } }, + { position: { x: 1900, y: 100 }, size: { width: 700, height: 400 } }, + { position: { x: 1100, y: 600 }, size: { width: 600, height: 300 } } + ]; + + const pos = findNearHomeSessionPosition(HOME_BOUNDS, existingCards, CARD_SIZE); + const bounds = { position: pos, size: CARD_SIZE }; + + assert.equal(boundsOverlapOrTouch(bounds, HOME_BOUNDS, 0), false); + for (const card of existingCards) { + assert.equal(boundsOverlapOrTouch(bounds, card, DEFAULT_SESSION_GAP), false); + } +});