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
4 changes: 2 additions & 2 deletions docs/UI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ This contract preserves the approved MVP concept and prevents feature ownership
- The built-in Browser is one movable, resizable core canvas card, not a plugin contribution. It uses the same `54px` outer window header as other canvas cards, with its identity and hide-card action isolated from the internal tab strip below. Its trusted DOM chrome owns compact tabs/favicons, address/search, back/forward/reload, downloads, per-tab provider badges, site dialogs, and explicit Close tab/Close all. Hiding the card keeps its tabs and shared authenticated Chromium profile alive. Below `0.5×`, during Edit HOME, behind trusted dialogs/popovers, and while overlapped by a higher renderer-owned canvas window, the native page is hidden and replaced with a stable semantic surface. During card or camera motion it remains live and follows frame-coalesced viewport geometry.
- 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. 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, and the command palette share the approved row grammar: official Lucide/provider assets, `--surface`/`--surface-soft`/semantic color tokens only, `0.7em` rows, `1.1em` surfaces, and `--shadow-lg`. Their dimensions are expressed in `em` from 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 configurable canvas-launcher subset and its safe reset defaults.
- 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.
- 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`.
- 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.
Expand Down
27 changes: 26 additions & 1 deletion src/main/services/SettingsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
MinimapInteractionMode,
PaletteId,
PluginCanvasInstance,
RadialLauncherItemId,
SessionRowColorMode,
ShortcutBindings,
StickyNote,
Expand All @@ -34,11 +35,13 @@ import {
DEFAULT_HOME_ACCENT_COLORS,
DEFAULT_HOME_GRID_SIZE,
DEFAULT_HOME_LAYOUT,
DEFAULT_RADIAL_LAUNCHER_ITEMS,
DEFAULT_UI_SCALE,
HOME_GRID_MAX_COLUMNS,
HOME_GRID_MAX_ROWS,
HOME_GRID_MIN_COLUMNS,
HOME_GRID_MIN_ROWS,
RADIAL_LAUNCHER_ITEMS,
STICKY_NOTE_MAX_SIZE,
STICKY_NOTE_MIN_SIZE,
UI_SCALE_MAX,
Expand All @@ -59,7 +62,7 @@ const SESSION_ROW_COLOR_MODES = new Set<SessionRowColorMode>(["monochrome", "sta
const CANVAS_COLORS = new Set<CanvasColorId>(["sage", "lilac", "night", "sand", "mist", "rose", "slate"]);
const PATTERNS = new Set<CanvasPatternId>(["dots", "grid", "waves", "diagonal", "rings", "none"]);
const MEDIA_FITS = new Set<MediaFit>(["cover", "contain"]);
const SETTINGS_VERSION = 13;
const SETTINGS_VERSION = 14;
const GROK_LAUNCHER_SETTINGS_VERSION = 3;
const EXPANDED_LIMIT_SETTINGS_VERSION = 5;
const QWEN_SETTINGS_VERSION = 6;
Expand All @@ -71,6 +74,7 @@ const PRE_QWEN_LIMIT_PROVIDERS: LimitProviderId[] = [...LEGACY_LIMIT_PROVIDERS,
const LIMIT_PROVIDERS: LimitProviderId[] = ["codex", "claude", "qwen", "kimi", "opencode", "grok"];
const LIMIT_PROVIDER_SET = new Set<LimitProviderId>(LIMIT_PROVIDERS);
const CANVAS_LAUNCHER_ITEM_SET = new Set<CanvasLauncherItemId>(CANVAS_LAUNCHER_ITEMS);
const RADIAL_LAUNCHER_ITEM_SET = new Set<RadialLauncherItemId>(RADIAL_LAUNCHER_ITEMS);
const EDGE_PAN_SPEEDS = new Set<EdgePanSpeed>(["slow", "normal", "fast"]);
const ZOOM_SENSITIVITIES = new Set<ZoomSensitivity>(["slow", "normal", "fast"]);
const FOCUS_ACTIVATIONS = new Set<FocusActivation>(["off", "single", "double"]);
Expand Down Expand Up @@ -133,6 +137,7 @@ export class SettingsStore {
|| !("homeLauncherProviders" in source)
|| !("homeLimitProviders" in source)
|| !("canvasLauncherItems" in source)
|| !("radialLauncherItems" in source)
|| !("agentLifecycleHooksEnabled" in source)
|| !("uiScale" in source)
|| !("canvasColor" in source)
Expand Down Expand Up @@ -246,6 +251,7 @@ function createDefaults(systemLocale: string, platform: CanvasNavigationPlatform
homeLauncherProviders: [...AGENT_PROVIDERS],
homeLimitProviders: [...LIMIT_PROVIDERS],
canvasLauncherItems: [...DEFAULT_CANVAS_LAUNCHER_ITEMS],
radialLauncherItems: [...DEFAULT_RADIAL_LAUNCHER_ITEMS],
agentLifecycleHooksEnabled: true,
uiScale: DEFAULT_UI_SCALE,
canvasColor: "sage",
Expand Down Expand Up @@ -343,6 +349,10 @@ export function normalizeSettings(
source.canvasLauncherItems,
fallback.canvasLauncherItems ?? DEFAULT_CANVAS_LAUNCHER_ITEMS
);
const radialLauncherItems = normalizeRadialLauncherItems(
source.radialLauncherItems,
fallback.radialLauncherItems ?? DEFAULT_RADIAL_LAUNCHER_ITEMS
);
const palette = PALETTES.has(source.palette as PaletteId) ? source.palette as PaletteId : fallback.palette;
const canvasColorCandidate = (source as Record<string, unknown>).canvasColor;
const canvasColor = canvasColorCandidate === undefined || canvasColorCandidate === "palette"
Expand Down Expand Up @@ -373,6 +383,7 @@ export function normalizeSettings(
homeLauncherProviders,
homeLimitProviders,
canvasLauncherItems,
radialLauncherItems,
agentLifecycleHooksEnabled: typeof source.agentLifecycleHooksEnabled === "boolean"
? source.agentLifecycleHooksEnabled
: fallback.agentLifecycleHooksEnabled,
Expand Down Expand Up @@ -463,6 +474,20 @@ export function normalizeCanvasLauncherItems(
return result.length > 0 ? result : [...fallback];
}

export function normalizeRadialLauncherItems(
candidate: unknown,
fallback: readonly RadialLauncherItemId[] = DEFAULT_RADIAL_LAUNCHER_ITEMS
): RadialLauncherItemId[] {
if (!Array.isArray(candidate)) return [...fallback];
const result: RadialLauncherItemId[] = [];
for (const item of candidate) {
if (typeof item !== "string" || !RADIAL_LAUNCHER_ITEM_SET.has(item as RadialLauncherItemId)) continue;
if (!result.includes(item as RadialLauncherItemId)) result.push(item as RadialLauncherItemId);
if (result.length === 8) break;
}
return result.length > 0 ? result : [...fallback];
}

export function normalizeUiScale(candidate: unknown, fallback = DEFAULT_UI_SCALE): number {
if (typeof candidate !== "number" || !Number.isFinite(candidate)) return fallback;
const stepped = Math.round(candidate / UI_SCALE_STEP) * UI_SCALE_STEP;
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
DEFAULT_HOME_GRID_SIZE,
DEFAULT_HOME_LAYOUT,
DEFAULT_CANVAS_LAUNCHER_ITEMS,
DEFAULT_RADIAL_LAUNCHER_ITEMS,
DEFAULT_UI_SCALE,
DEFAULT_SHORTCUTS
} from "../../shared/contracts";
Expand Down Expand Up @@ -76,6 +77,7 @@ const FALLBACK_SETTINGS: AppSettings = {
homeLauncherProviders: ["codex", "claude", "qwen", "kimi", "opencode", "hermes", "grok"],
homeLimitProviders: ["codex", "claude", "qwen", "kimi", "opencode", "grok"],
canvasLauncherItems: [...DEFAULT_CANVAS_LAUNCHER_ITEMS],
radialLauncherItems: [...DEFAULT_RADIAL_LAUNCHER_ITEMS],
agentLifecycleHooksEnabled: true,
uiScale: DEFAULT_UI_SCALE,
canvasColor: "sage",
Expand Down
147 changes: 147 additions & 0 deletions src/renderer/src/features/launcher/QuickRadialMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { useEffect, useRef, useState } from "react";
import type {
LocaleId,
Point,
ProviderId,
RadialLauncherItemId
} from "../../../../shared/contracts";
import { ProviderIcon } from "../../components/ProviderIcon";
import { UiIcon } from "../../components/UiIcon";
import { t } from "../../lib/i18n";
import { PROVIDERS } from "../../lib/providers";
import { radialItemAtPointer, radialItemOffset } from "./radialLauncher";

interface RadialLauncherProps {
anchor: Point;
pointerAnchor: Point;
items: RadialLauncherItemId[];
locale: LocaleId;
pointerId: number;
onActivate(item: RadialLauncherItemId, fromPointerRelease?: boolean): void;
onClose(reason?: "release" | "cancel"): void;
}

const PROVIDER_IDS = new Set<ProviderId>([
"terminal", "codex", "claude", "qwen", "kimi", "opencode", "hermes", "grok"
]);

export function RadialLauncher({
anchor,
pointerAnchor,
items,
locale,
pointerId,
onActivate,
onClose
}: RadialLauncherProps): React.JSX.Element {
const [highlighted, setHighlighted] = useState<number | null>(null);
const highlightedRef = useRef(highlighted);
highlightedRef.current = highlighted;
const menuRef = useRef<HTMLDivElement>(null);

useEffect(() => {
menuRef.current?.focus({ preventScroll: true });
}, []);

useEffect(() => {
const handleMove = (event: PointerEvent): void => {
if (event.pointerId !== pointerId) return;
const next = radialItemAtPointer(pointerAnchor, { x: event.clientX, y: event.clientY }, items.length);
highlightedRef.current = next;
setHighlighted(next);
};
const handleUp = (event: PointerEvent): void => {
if (event.pointerId !== pointerId || event.button !== 2) return;
const item = highlightedRef.current === null ? null : items[highlightedRef.current];
if (item) onActivate(item, true);
else onClose("release");
};
const handleDown = (event: PointerEvent): void => {
if (event.button === 0 && !(event.target instanceof Element && event.target.closest(".radial-launcher"))) {
onClose("cancel");
}
};
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key === "Escape") {
event.preventDefault();
onClose("cancel");
return;
}
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
event.preventDefault();
setHighlighted((current) => current === null ? 0 : (current + 1) % items.length);
return;
}
if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
event.preventDefault();
setHighlighted((current) => current === null ? items.length - 1 : (current - 1 + items.length) % items.length);
return;
}
if ((event.key === "Enter" || event.key === " ") && highlightedRef.current !== null) {
event.preventDefault();
onActivate(items[highlightedRef.current]);
}
};
window.addEventListener("pointermove", handleMove, true);
window.addEventListener("pointerup", handleUp, true);
window.addEventListener("pointerdown", handleDown, true);
window.addEventListener("keydown", handleKeyDown, true);
return () => {
window.removeEventListener("pointermove", handleMove, true);
window.removeEventListener("pointerup", handleUp, true);
window.removeEventListener("pointerdown", handleDown, true);
window.removeEventListener("keydown", handleKeyDown, true);
};
}, [items, onActivate, onClose, pointerAnchor, pointerId]);

return (
<div
ref={menuRef}
className="radial-launcher"
style={{ left: anchor.x, top: anchor.y }}
role="menu"
aria-label={t(locale, "quickLauncher")}
tabIndex={-1}
data-interactive="true"
onContextMenu={(event) => event.preventDefault()}
>
<div className="radial-launcher__center" aria-hidden="true">
<span>{t(locale, "releaseToLaunch")}</span>
</div>
{items.map((item, index) => {
const offset = radialItemOffset(index, items.length);
const active = highlighted === index;
return (
<button
key={item}
className={`radial-launcher__item ${active ? "radial-launcher__item--active" : ""}`}
style={{ transform: `translate(calc(-50% + ${offset.x}px), calc(-50% + ${offset.y}px))` }}
type="button"
role="menuitem"
aria-current={active ? "true" : undefined}
onPointerEnter={() => setHighlighted(index)}
onFocus={() => setHighlighted(index)}
onClick={() => onActivate(item)}
>
<span className="radial-launcher__icon">{renderIcon(item)}</span>
<span>{itemLabel(locale, item)}</span>
</button>
);
})}
</div>
);
}

function renderIcon(item: RadialLauncherItemId): React.JSX.Element {
if (PROVIDER_IDS.has(item as ProviderId)) return <ProviderIcon provider={item as ProviderId} size="small" />;
if (item === "browser") return <UiIcon name="browser" size={20} />;
if (item === "settings") return <UiIcon name="settings" size={20} />;
return <UiIcon name="sticky-note" size={20} />;
}

export function itemLabel(locale: LocaleId, item: RadialLauncherItemId): string {
if (item === "note") return t(locale, "stickyNote");
if (item === "browser") return t(locale, "browser");
if (item === "settings") return t(locale, "settings");
return PROVIDERS[item].label;
}
Loading