Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ When session restore is on, startup loads validated records before the renderer

`SessionMetadata` owns both world-space position and card size. `App` reconciles those bounds, while `TerminalCard` may hold transient pointer-move geometry until pointer-up. The main process validates and clamps committed sizes before emitting a session snapshot. Camera wheel handling is limited to empty canvas; interactive surfaces keep their native scroll/input ownership.

The camera is not React state. `App` owns a `cameraStore` (`features/workspace/cameraStore.ts`); `WorkspaceCanvas` writes the scene transform from the store's listener, synchronously and before anything that measures the scene renders. Only the minimap and `BrowserCard` subscribe to every move; cards subscribe to derived values (`useCameraSelector`: summary scale, WebGL eligibility) and drag handlers read `camera.get().zoom` when they move, so a pan or zoom renders no card.
The camera is not React state. `App` owns a `cameraStore` (`features/workspace/cameraStore.ts`); `WorkspaceCanvas` writes the scene transform from the store's listener, synchronously and before anything that measures the scene renders. Only the minimap's camera rectangle and `BrowserCard` subscribe to every move; cards subscribe to derived values (`useCameraSelector`: summary scale, WebGL eligibility) and drag handlers read `camera.get().zoom` when they move, so a pan or zoom renders no card. The minimap fits HOME and occupied canvas bounds independently of the camera. Its workspace layer is memoized by geometry, provider, region color and layer order, ignoring session output, titles and activity; camera updates project only one viewport rectangle in constant time, without rebuilding the object list or provider icons.

A live `TerminalCard` owns one xterm instance for the lifetime of its session ID. Palette changes update `terminal.options.theme` in place; title and settings changes must never dispose the terminal or its renderer-side scrollback. Window titles are updated as session metadata through `terminal:rename`. PTY input and resize events that race with process exit are contained at the main-process boundary and never surface as uncaught Electron errors.

Expand Down
2 changes: 1 addition & 1 deletion docs/UI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ This contract preserves the approved MVP concept and prevents feature ownership
- 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.
- Empty-canvas drag and middle-button drag pan the canvas. On fresh profiles ordinary scroll over empty canvas pans on both axes; pinch and `Cmd/Ctrl + scroll` zoom around the pointer. **Use scroll wheel to zoom** restores historical ordinary-wheel zoom and is enabled by migration for existing profiles. Only a focused input widget interrupts canvas wheel/pinch, except the HOME session list keeps ordinary local scrolling without a focus precondition; unfocused or non-focusable widgets otherwise leave those events to the canvas. **Wheel/pinch capture over widgets** has Off, On, and Key modes: Off preserves local ownership, On always transfers only wheel/pinch to the canvas, and Key transfers only wheel/pinch while its binding is held. Key and full-navigation bindings accept modifier chords or Mouse3/Mouse4/Mouse5. A focused live Browser page therefore scrolls normally in Off or released-Key mode; unfocused Browser content, On, an active Key binding, the full override, pinch, and `Cmd/Ctrl + scroll` are canvas-owned. The owner selected for a Browser wheel sequence is held until 250 ms of wheel inactivity. Browser summaries and placeholders are always canvas-owned. Fresh profiles use Key with `Command` on macOS or `Ctrl` elsewhere. A separate configurable **Canvas navigation override** (default `Option` on macOS and `Alt` elsewhere) transfers wheel/pinch and drag while held. While full override is held all canvas surfaces show `grab`; an owned drag shows `grabbing` until pointer up/cancel even after the binding is released.
- The minimap is an RTS-style camera-centred radar over the infinite Canvas. Its projection uses one uniform scale and never auto-fits around HOME or visible objects, so pan moves HOME, regions, and fixed-size window markers through the frame without stretching them. The white camera rectangle stays centred at `46 × 30px`; HOME and windows may cross and leave the radar. Controls chooses between two mutually exclusive modes: **Click** (the default, which recentres without changing zoom) and **Drag**. Drag mirrors empty-canvas grab semantics: pointer-down never jumps the camera, moving right/down pulls the scene right/down from the gesture's starting camera, and pointer capture continues outside the radar. Pressing and releasing without movement in Drag mode never recentres the camera. Arrow keys always pan it from keyboard focus. Minimap, passive shortcut hints, and Home/zoom controls share four corner slots and stack instead of overlapping when assigned to the same corner.
- The minimap fits the occupied workspace: HOME, regions, terminal/agent windows, renderable plugin cards, the visible Browser card, and sticky notes. It uses one uniform scale with padding, so every rectangle preserves its real proportions and relative size. Window stacking follows the Canvas. Sessions have no text labels; their existing provider icons appear only when they fit inside the projected rectangle, without enlarging it. Pan and zoom leave the overview fixed and move/resize only the white camera rectangle; a white edge marker locates the camera when its viewport is entirely outside the overview. Moving or resizing an individual window or HOME updates the layout after pointer release. Adding, closing, or repositioning objects refits the overview, and a distant object can therefore make all rectangles smaller. Controls chooses between two mutually exclusive modes: **Click** (the default, which recentres without changing zoom) and **Drag**. Drag mirrors empty-canvas grab semantics: pointer-down never jumps the camera, moving right/down pulls the scene right/down from the gesture's starting camera, and pointer capture continues outside the minimap. Pressing and releasing without movement in Drag mode never recentres the camera. Arrow keys always pan it from keyboard focus. Minimap, passive shortcut hints, and Home/zoom controls share four corner slots and stack instead of overlapping when assigned to the same corner.
- The canvas edge-pans RTS-style while the pointer rests within `56px` of a viewport edge over empty canvas; speed ramps linearly up to `900px/s` at the edge itself. Edge panning is off by default and enabled in Settings. Motion pauses over interactive surfaces (terminal cards, Home, controls) and while drag-panning.
- Dialog close actions stay inside their own header/control row with a consistent inset; they never overlap a field, outline, or panel boundary.

Expand Down
142 changes: 84 additions & 58 deletions src/renderer/src/features/workspace/CanvasMinimap.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,31 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { memo, useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, RefObject } from "react";
import type {
BrowserCanvasState,
CameraState,
CanvasRegion,
LocaleId,
MinimapInteractionMode,
PluginCanvasInstance,
ProviderId,
SessionBounds,
SessionSnapshot,
Size,
StickyNote
Size
} from "../../../../shared/contracts";
import { ProviderIcon } from "../../components/ProviderIcon";
import { t } from "../../lib/i18n";
import { useCameraSelector, type CameraStore } from "./cameraStore";
import { browserLayerId, noteLayerId, pluginLayerId, terminalLayerId } from "./canvasSelectionGesture";
import { minimapContentEqual, type MinimapContent } from "./minimapContent";
import {
cameraWorldViewport,
minimapCameraForPointerDrag,
minimapAreaForBounds,
minimapEdgePointForBounds,
minimapPointForBounds,
MINIMAP_SURFACE_SIZE,
minimapWorldBounds,
minimapWorldPoint
} from "./minimapGeometry";

interface CanvasMinimapProps {
interface CanvasMinimapProps extends MinimapContent {
viewport: RefObject<HTMLDivElement | null>;
camera: CameraState;
homeBounds: SessionBounds;
canvasRegions: readonly CanvasRegion[];
sessions: readonly SessionSnapshot[];
stickyNotes: readonly StickyNote[];
pluginCanvas: readonly PluginCanvasInstance[];
browserCanvas: BrowserCanvasState | null;
camera: CameraStore;
locale: LocaleId;
interactionMode: MinimapInteractionMode;
onCameraChange(camera: CameraState): void;
Expand All @@ -41,6 +35,7 @@ interface MinimapEntity {
id: string;
kind: "terminal" | "plugin" | "browser" | "note";
bounds: SessionBounds;
provider?: ProviderId;
}

interface MinimapDragState {
Expand All @@ -50,7 +45,16 @@ interface MinimapDragState {
moved: boolean;
}

export function CanvasMinimap({
export const CanvasMinimap = memo(CanvasMinimapView, (previous, next) => (
previous.viewport === next.viewport
&& previous.camera === next.camera
&& previous.locale === next.locale
&& previous.interactionMode === next.interactionMode
&& previous.onCameraChange === next.onCameraChange
&& minimapContentEqual(previous, next)
));

function CanvasMinimapView({
viewport,
camera,
homeBounds,
Expand All @@ -59,15 +63,14 @@ export function CanvasMinimap({
stickyNotes,
pluginCanvas,
browserCanvas,
layerOrder,
locale,
interactionMode,
onCameraChange
}: CanvasMinimapProps): React.JSX.Element {
const surface = useRef<HTMLSpanElement>(null);
const cameraRef = useRef(camera);
const dragState = useRef<MinimapDragState | null>(null);
const [viewportSize, setViewportSize] = useState<Size>({ width: 1, height: 1 });
cameraRef.current = camera;

useEffect(() => {
const element = viewport.current;
Expand All @@ -85,34 +88,26 @@ export function CanvasMinimap({
return () => observer.disconnect();
}, [viewport]);

const worldViewport = useMemo(
() => cameraWorldViewport(camera, viewportSize),
[camera, viewportSize]
);
const worldBounds = useMemo(() => minimapWorldBounds(worldViewport), [worldViewport]);
const viewportPoint = useMemo(
() => minimapPointForBounds(worldViewport, worldBounds),
[worldBounds, worldViewport]
const entities = useMemo<MinimapEntity[]>(() => [
...sessions.map((session) => ({
id: terminalLayerId(session.id), kind: "terminal" as const, bounds: session, provider: session.provider
})),
...stickyNotes.map((note) => ({ id: noteLayerId(note.id), kind: "note" as const, bounds: note })),
...pluginCanvas.map((instance) => ({ id: pluginLayerId(instance.id), kind: "plugin" as const, bounds: instance })),
...(browserCanvas ? [{ id: browserLayerId, kind: "browser" as const, bounds: browserCanvas }] : [])
], [browserCanvas, pluginCanvas, sessions, stickyNotes]);
const layerIndices = useMemo(
() => new Map(layerOrder.map((id, index) => [id, index + 1])),
[layerOrder]
);
const homeEdge = useMemo(
() => minimapEdgePointForBounds(homeBounds, worldBounds),
[homeBounds, worldBounds]
const worldBounds = useMemo(
() => minimapWorldBounds([homeBounds, ...canvasRegions, ...entities.map((entity) => entity.bounds)]),
[canvasRegions, entities, homeBounds]
);
const homeArea = useMemo(
() => minimapAreaForBounds(homeBounds, worldBounds),
[homeBounds, worldBounds]
);
const entities = useMemo<MinimapEntity[]>(() => [
...sessions.map((session) => ({ id: session.id, kind: "terminal" as const, bounds: session })),
...stickyNotes.map((note) => ({ id: note.id, kind: "note" as const, bounds: note })),
...pluginCanvas.map((instance) => ({ id: instance.id, kind: "plugin" as const, bounds: instance })),
...(browserCanvas ? [{ id: "browser", kind: "browser" as const, bounds: browserCanvas }] : [])
], [browserCanvas, pluginCanvas, sessions, stickyNotes]);

const applyCamera = (next: CameraState): void => {
cameraRef.current = next;
onCameraChange(next);
};

const navigate = (clientX: number, clientY: number): void => {
const element = surface.current;
Expand All @@ -123,8 +118,8 @@ export function CanvasMinimap({
x: (clientX - bounds.left) / bounds.width,
y: (clientY - bounds.top) / bounds.height
}, worldBounds);
const current = cameraRef.current;
applyCamera({
const current = camera.get();
onCameraChange({
zoom: current.zoom,
x: viewportSize.width / 2 - target.x * current.zoom,
y: viewportSize.height / 2 - target.y * current.zoom
Expand Down Expand Up @@ -153,7 +148,7 @@ export function CanvasMinimap({
worldBounds
);
if (!next) return;
applyCamera(next);
onCameraChange(next);
};

return (
Expand All @@ -173,7 +168,7 @@ export function CanvasMinimap({
dragState.current = {
pointerId: event.pointerId,
startClient: { x: event.clientX, y: event.clientY },
startCamera: cameraRef.current,
startCamera: camera.get(),
moved: false
};
} else {
Expand Down Expand Up @@ -210,8 +205,8 @@ export function CanvasMinimap({
: null;
if (!delta) return;
event.preventDefault();
const current = cameraRef.current;
applyCamera({ ...current, x: current.x + delta.x, y: current.y + delta.y });
const current = camera.get();
onCameraChange({ ...current, x: current.x + delta.x, y: current.y + delta.y });
}}
>
<span className="canvas-minimap__surface" ref={surface} aria-hidden="true">
Expand All @@ -226,22 +221,53 @@ export function CanvasMinimap({
) : null;
})}
{homeArea && <i className="canvas-minimap__home" style={areaStyle(homeArea)} />}
{entities.map((entity) => (
<i
className={`canvas-minimap__entity canvas-minimap__entity--${entity.kind}`}
key={`${entity.kind}:${entity.id}`}
style={pointStyle(minimapPointForBounds(entity.bounds, worldBounds))}
/>
))}
<i className="canvas-minimap__viewport" style={pointStyle(viewportPoint)} />
{homeEdge && (
<i className="canvas-minimap__home-edge" style={pointStyle(homeEdge)} />
)}
<span className="canvas-minimap__windows">
{entities.map((entity) => {
const area = minimapAreaForBounds(entity.bounds, worldBounds);
if (!area) return null;
const iconSize = Math.min(16,
area.width * MINIMAP_SURFACE_SIZE.width - 4,
area.height * MINIMAP_SURFACE_SIZE.height - 4
);
return (
<i
className={`canvas-minimap__entity canvas-minimap__entity--${entity.kind}`}
key={entity.id}
style={{
...areaStyle(area),
zIndex: layerIndices.get(entity.id) ?? 1,
"--minimap-icon-size": `${iconSize}px`
} as CSSProperties}
>
{entity.provider && iconSize >= 8 && <ProviderIcon provider={entity.provider} size="small" />}
</i>
);
})}
</span>
<MinimapViewport camera={camera} viewportSize={viewportSize} worldBounds={worldBounds} />
</span>
</button>
);
}

// Only this layer subscribes to pan/zoom; the workspace rectangles and icons do not render per camera event.
function MinimapViewport({ camera, viewportSize, worldBounds }: {
camera: CameraStore;
viewportSize: Size;
worldBounds: SessionBounds;
}): React.JSX.Element {
const current = useCameraSelector(camera, (value) => value);
const worldViewport = cameraWorldViewport(current, viewportSize);
const area = minimapAreaForBounds(worldViewport, worldBounds);
const edge = minimapEdgePointForBounds(worldViewport, worldBounds);
return (
<>
{area && <i className="canvas-minimap__viewport" style={areaStyle(area)} />}
{edge && <i className="canvas-minimap__viewport-edge" style={pointStyle(edge)} />}
</>
);
}

function pointStyle(point: { x: number; y: number }): CSSProperties {
return { left: `${point.x * 100}%`, top: `${point.y * 100}%` };
}
Expand Down
15 changes: 7 additions & 8 deletions src/renderer/src/features/workspace/WorkspaceCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,10 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element
candidate.id === instance.contributionId && candidate.kind === "canvas-app"
));
}).map((instance) => instance.id)), [plugins, settings.pluginCanvas]);
const minimapPluginCanvas = useMemo(
() => renderedPluginCanvas.filter((instance) => renderablePluginIds.has(instance.id)),
[renderedPluginCanvas, renderablePluginIds]
);
const activeLayerIds = useMemo(() => [
...renderedSessions.map((session) => terminalLayerId(session.id)),
...renderedPluginCanvas.filter((instance) => renderablePluginIds.has(instance.id)).map((instance) => pluginLayerId(instance.id)),
Expand Down Expand Up @@ -1328,9 +1332,10 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element
</section>
)}
{settings.minimapPlacement === placement && (
<LiveCanvasMinimap viewport={viewport} camera={camera} homeBounds={homeBounds}
<CanvasMinimap viewport={viewport} camera={camera} homeBounds={homeBounds}
canvasRegions={renderedCanvasRegions} sessions={renderedSessions} stickyNotes={renderedStickyNotes}
pluginCanvas={renderedPluginCanvas} browserCanvas={renderedBrowserCanvas}
pluginCanvas={minimapPluginCanvas}
browserCanvas={renderedBrowserCanvas} layerOrder={layerOrder}
locale={settings.locale} interactionMode={settings.minimapInteractionMode}
onCameraChange={commitCamera} />
)}
Expand Down Expand Up @@ -1391,9 +1396,3 @@ function shouldKeepCanvasContextMenu(target: EventTarget | null): boolean {
"textarea, input, select, [contenteditable='true'], .terminal-card, .plugin-canvas-card, .browser-card, .home-zone, .canvas-overlays, .canvas-menu, .canvas-region-editor, [data-canvas-region-id], [data-sticky-note-id], [data-interactive='true']"
));
}

/** The minimap draws the camera's viewport, so it is the one part of the HUD that renders on every move. */
function LiveCanvasMinimap(props: Omit<React.ComponentProps<typeof CanvasMinimap>, "camera"> & { camera: CameraStore }): React.JSX.Element {
const camera = useCameraSelector(props.camera, (current) => current);
return <CanvasMinimap {...props} camera={camera} />;
}
2 changes: 1 addition & 1 deletion src/renderer/src/features/workspace/cameraStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { CameraState } from "../../../../shared/contracts";
* The canvas camera outside React state. A pan or zoom changes it on every pointer or wheel event; kept in
* App state it rendered the whole application tree per event. The store applies the scene transform
* directly (see `sceneTransform`), and only the parts that really depend on the camera subscribe:
* the minimap and the browser card (its native view follows the card) take every change, cards take
* the minimap's camera rectangle and the browser card (its native view follows the card) take every change, cards take
* derived values that change rarely (summary mode, WebGL eligibility), and pointer handlers read `get()`.
*/
export interface CameraStore {
Expand Down
Loading
Loading