diff --git a/src/desktop-electron/bridge.ts b/src/desktop-electron/bridge.ts index bb63679..c152a48 100644 --- a/src/desktop-electron/bridge.ts +++ b/src/desktop-electron/bridge.ts @@ -8,6 +8,7 @@ export const REALTIME_UNSUBSCRIBE_CHANNEL = "ica:realtime:unsubscribe" as const; export const CONTROL_PLANE_IPC_CHANNEL = "ica:control-plane:request" as const; export const DESKTOP_PICK_PROJECT_IPC_CHANNEL = "ica:desktop:pick-project" as const; export const DESKTOP_PICK_PUBLISH_IPC_CHANNEL = "ica:desktop:pick-publish" as const; +export const DESKTOP_OPEN_SETTINGS_IPC_CHANNEL = "ica:desktop:open-settings" as const; export const DESKTOP_RUNTIME_INFO_IPC_CHANNEL = "ica:desktop:runtime-info" as const; export const DESKTOP_REPORT_FAILURE_IPC_CHANNEL = "ica:desktop:report-failure" as const; export const DESKTOP_UPDATE_CHECK_IPC_CHANNEL = "ica:desktop:update-check" as const; @@ -53,6 +54,7 @@ export interface DesktopBridgeApi { quitAndInstallAppUpdate(): Promise<{ accepted: boolean }>; pickProjectDirectory(initialPath?: string): Promise<{ path: string }>; pickPublishDirectory(initialPath?: string): Promise<{ path: string }>; + openSettingsWindow(): Promise; getRuntimeInfo(): Promise; reportRendererFailure(payload: DesktopHostFailureReport): Promise; } diff --git a/src/desktop-electron/main.ts b/src/desktop-electron/main.ts index 71c153b..a8a4123 100644 --- a/src/desktop-electron/main.ts +++ b/src/desktop-electron/main.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { app, BrowserWindow, ipcMain } from "electron"; import { CONTROL_PLANE_IPC_CHANNEL, + DESKTOP_OPEN_SETTINGS_IPC_CHANNEL, DESKTOP_PICK_PROJECT_IPC_CHANNEL, DESKTOP_PICK_PUBLISH_IPC_CHANNEL, DESKTOP_REPORT_FAILURE_IPC_CHANNEL, @@ -36,6 +37,31 @@ export interface RegisterElectronDesktopBridgeOptions { logger?: DesktopStartupLogger; } +function buildDesktopWindowUrl(startUrl: string, windowRole: "main" | "settings"): string { + const url = new URL(startUrl); + url.searchParams.set("windowRole", windowRole); + return url.toString(); +} + +async function loadDesktopWindow(window: BrowserWindow, targetUrl: string, logger: DesktopStartupLogger): Promise { + installDesktopLoadDiagnostics(window, targetUrl, logger); + try { + await window.loadURL(targetUrl); + } catch (error) { + const reason = normalizeStartupErrorMessage(error); + logger.error(`[desktop] Initial renderer load rejected for '${targetUrl}': ${reason}`); + await showDesktopLoadFailure( + window, + { + attemptedUrl: targetUrl, + failingUrl: targetUrl, + reason, + }, + logger, + ); + } +} + export async function registerElectronDesktopBridge( options: RegisterElectronDesktopBridgeOptions = {}, ): Promise<{ createWindow: () => Promise; dispose: () => Promise }> { @@ -54,6 +80,60 @@ export async function registerElectronDesktopBridge( applicationService: options.applicationService, }); const subscriptions = new Map void>(); + let mainWindow: BrowserWindow | null = null; + let settingsWindow: BrowserWindow | null = null; + + async function createMainWindow(): Promise { + if (mainWindow && !mainWindow.isDestroyed()) { + return mainWindow; + } + + const window = new BrowserWindow({ + width: 1440, + height: 940, + show: true, + webPreferences: { + preload: path.join(__dirname, "preload.js"), + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + }, + }); + window.webContents.on("destroyed", () => { + if (mainWindow === window) { + mainWindow = null; + } + }); + mainWindow = window; + await loadDesktopWindow(window, buildDesktopWindowUrl(startUrl, "main"), logger); + return window; + } + + async function createSettingsWindow(): Promise { + if (settingsWindow && !settingsWindow.isDestroyed()) { + return settingsWindow; + } + + const window = new BrowserWindow({ + width: 1120, + height: 860, + show: true, + webPreferences: { + preload: path.join(__dirname, "preload.js"), + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + }, + }); + window.webContents.on("destroyed", () => { + if (settingsWindow === window) { + settingsWindow = null; + } + }); + settingsWindow = window; + await loadDesktopWindow(window, buildDesktopWindowUrl(startUrl, "settings"), logger); + return window; + } ipcMain.handle(CONTROL_PLANE_IPC_CHANNEL, async (_event, channel: keyof DesktopBridgeRequestMap, payload: DesktopBridgeRequestMap[keyof DesktopBridgeRequestMap]) => { if (channel !== "control-plane.request") { @@ -63,6 +143,11 @@ export async function registerElectronDesktopBridge( }); ipcMain.handle(DESKTOP_PICK_PROJECT_IPC_CHANNEL, async (_event, initialPath?: string) => controlPlane.pickProjectDirectory(initialPath)); ipcMain.handle(DESKTOP_PICK_PUBLISH_IPC_CHANNEL, async (_event, initialPath?: string) => controlPlane.pickPublishDirectory(initialPath)); + ipcMain.handle(DESKTOP_OPEN_SETTINGS_IPC_CHANNEL, async () => { + const window = await createSettingsWindow(); + window.show(); + window.focus(); + }); ipcMain.handle(DESKTOP_RUNTIME_INFO_IPC_CHANNEL, async () => controlPlane.getRuntimeInfo()); ipcMain.handle(DESKTOP_REPORT_FAILURE_IPC_CHANNEL, async (_event, payload: DesktopHostFailureReport) => { await controlPlane.reportRendererFailure(payload); @@ -95,34 +180,7 @@ export async function registerElectronDesktopBridge( return { async createWindow() { await app.whenReady(); - const window = new BrowserWindow({ - width: 1440, - height: 940, - show: true, - webPreferences: { - preload: path.join(__dirname, "preload.js"), - contextIsolation: true, - nodeIntegration: false, - sandbox: false, - }, - }); - installDesktopLoadDiagnostics(window, startUrl, logger); - try { - await window.loadURL(startUrl); - } catch (error) { - const reason = normalizeStartupErrorMessage(error); - logger.error(`[desktop] Initial renderer load rejected for '${startUrl}': ${reason}`); - await showDesktopLoadFailure( - window, - { - attemptedUrl: startUrl, - failingUrl: startUrl, - reason, - }, - logger, - ); - } - return window; + return createMainWindow(); }, async dispose() { for (const unsubscribe of subscriptions.values()) { @@ -132,6 +190,7 @@ export async function registerElectronDesktopBridge( ipcMain.removeHandler(CONTROL_PLANE_IPC_CHANNEL); ipcMain.removeHandler(DESKTOP_PICK_PROJECT_IPC_CHANNEL); ipcMain.removeHandler(DESKTOP_PICK_PUBLISH_IPC_CHANNEL); + ipcMain.removeHandler(DESKTOP_OPEN_SETTINGS_IPC_CHANNEL); ipcMain.removeHandler(DESKTOP_RUNTIME_INFO_IPC_CHANNEL); ipcMain.removeHandler(DESKTOP_REPORT_FAILURE_IPC_CHANNEL); ipcMain.removeHandler(DESKTOP_UPDATE_CHECK_IPC_CHANNEL); @@ -139,6 +198,8 @@ export async function registerElectronDesktopBridge( ipcMain.removeHandler(DESKTOP_UPDATE_QUIT_AND_INSTALL_IPC_CHANNEL); ipcMain.removeAllListeners(REALTIME_SUBSCRIBE_CHANNEL); ipcMain.removeAllListeners(REALTIME_UNSUBSCRIBE_CHANNEL); + mainWindow = null; + settingsWindow = null; await controlPlane.close(); }, }; diff --git a/src/desktop-electron/preload.ts b/src/desktop-electron/preload.ts index 47cdf65..da0f2a5 100644 --- a/src/desktop-electron/preload.ts +++ b/src/desktop-electron/preload.ts @@ -6,6 +6,7 @@ import { DESKTOP_UPDATE_QUIT_AND_INSTALL_IPC_CHANNEL, DESKTOP_PICK_PROJECT_IPC_CHANNEL, DESKTOP_PICK_PUBLISH_IPC_CHANNEL, + DESKTOP_OPEN_SETTINGS_IPC_CHANNEL, DESKTOP_REPORT_FAILURE_IPC_CHANNEL, DESKTOP_RUNTIME_INFO_IPC_CHANNEL, REALTIME_EVENT_CHANNEL, @@ -60,6 +61,10 @@ const desktopBridge: DesktopBridgeApi = { return ipcRenderer.invoke(DESKTOP_PICK_PUBLISH_IPC_CHANNEL, initialPath) as Promise<{ path: string }>; }, + openSettingsWindow() { + return ipcRenderer.invoke(DESKTOP_OPEN_SETTINGS_IPC_CHANNEL) as Promise; + }, + getRuntimeInfo() { return ipcRenderer.invoke(DESKTOP_RUNTIME_INFO_IPC_CHANNEL) as Promise; }, diff --git a/src/installer-dashboard/web/src/App.tsx b/src/installer-dashboard/web/src/App.tsx index 6448c13..23b9374 100644 --- a/src/installer-dashboard/web/src/App.tsx +++ b/src/installer-dashboard/web/src/App.tsx @@ -1,14 +1,17 @@ import React from "react"; import { InstallerDashboard } from "./InstallerDashboard"; +import { resolveDashboardWindowRole } from "./window-role"; export function App(): JSX.Element { + const windowRole = resolveDashboardWindowRole(); + return ( -
+ ); diff --git a/src/installer-dashboard/web/src/DesktopAppearanceSettings.tsx b/src/installer-dashboard/web/src/DesktopAppearanceSettings.tsx new file mode 100644 index 0000000..4e515b1 --- /dev/null +++ b/src/installer-dashboard/web/src/DesktopAppearanceSettings.tsx @@ -0,0 +1,87 @@ +import React from "react"; +import { + accentOptions, + backgroundOptions, + modeOptions, + type DashboardAccent, + type DashboardBackground, + type DashboardMode, +} from "./appearance"; + +interface DesktopAppearanceSettingsProps { + mode: DashboardMode; + accent: DashboardAccent; + background: DashboardBackground; + onModeChange(mode: DashboardMode): void; + onAccentChange(accent: DashboardAccent): void; + onBackgroundChange(background: DashboardBackground): void; +} + +export function DesktopAppearanceSettings({ + mode, + accent, + background, + onModeChange, + onAccentChange, + onBackgroundChange, +}: DesktopAppearanceSettingsProps): JSX.Element { + return ( +
+

Desktop Preferences

+

Appearance & Theme

+

Theme controls live in the dedicated Settings window so the main shell stays focused on workspace operations.

+
+
+ Theme +
+ {modeOptions.map((option) => ( + + ))} +
+
+
+ Accent +
+ {accentOptions.map((option) => ( + + ))} +
+
+
+
+ Background +
+ {backgroundOptions.map((option) => ( + + ))} +
+
+
+ ); +} diff --git a/src/installer-dashboard/web/src/InstallerDashboard.tsx b/src/installer-dashboard/web/src/InstallerDashboard.tsx index d25d5d4..34e6351 100644 --- a/src/installer-dashboard/web/src/InstallerDashboard.tsx +++ b/src/installer-dashboard/web/src/InstallerDashboard.tsx @@ -1,14 +1,18 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { checkAppUpdate, controlPlaneFetch, downloadAppUpdate, + openSettingsWindow, pickProjectDirectory, pickPublishDirectory, quitAndInstallAppUpdate, } from "./control-plane-client"; +import { DesktopAppearanceSettings } from "./DesktopAppearanceSettings"; import { describeRealtimeStatus, summarizeRealtimeEvent } from "./desktop-shell"; +import { useDashboardAppearance } from "./appearance"; import { startRealtimeClient, type RealtimeEvent, type RealtimeStatus } from "./realtime-client"; +import type { DashboardWindowRole } from "./window-role"; import type { AppUpdateStatus } from "../../../installer-core/updateCheck"; type Target = "claude" | "codex" | "cursor" | "gemini" | "antigravity"; @@ -166,89 +170,9 @@ type SkillPublishResult = { type PublishMode = "direct-push" | "branch-only" | "branch-pr"; type DesktopUpdateTone = "neutral" | "busy" | "success" | "danger"; -type DashboardTab = "skills" | "hooks" | "settings" | "state"; -type DashboardMode = "light" | "dark"; -type DashboardAccent = "slate" | "blue" | "red" | "green" | "amber"; -type DashboardBackground = "slate" | "ocean" | "sand" | "forest" | "wine"; -type LegacyDashboardTheme = "light" | "dark" | "blue" | "red" | "green"; +type DashboardTab = "skills" | "hooks" | "state"; const allTargets: Target[] = ["claude", "codex", "cursor", "gemini", "antigravity"]; -const modeStorageKey = "ica.dashboard.mode"; -const accentStorageKey = "ica.dashboard.accent"; -const backgroundStorageKey = "ica.dashboard.background"; -const legacyThemeStorageKey = "ica.dashboard.theme"; -const modeOptions: Array<{ id: DashboardMode; label: string }> = [ - { id: "light", label: "Light" }, - { id: "dark", label: "Dark" }, -]; -const accentOptions: Array<{ id: DashboardAccent; label: string }> = [ - { id: "slate", label: "Slate" }, - { id: "blue", label: "Blue" }, - { id: "red", label: "Red" }, - { id: "green", label: "Green" }, - { id: "amber", label: "Amber" }, -]; -const backgroundOptions: Array<{ id: DashboardBackground; label: string }> = [ - { id: "slate", label: "Slate" }, - { id: "ocean", label: "Ocean" }, - { id: "sand", label: "Sand" }, - { id: "forest", label: "Forest" }, - { id: "wine", label: "Wine" }, -]; - -function isDashboardMode(value: string | null): value is DashboardMode { - return value === "light" || value === "dark"; -} - -function isDashboardAccent(value: string | null): value is DashboardAccent { - return value === "slate" || value === "blue" || value === "red" || value === "green" || value === "amber"; -} - -function isDashboardBackground(value: string | null): value is DashboardBackground { - return value === "slate" || value === "ocean" || value === "sand" || value === "forest" || value === "wine"; -} - -function isLegacyTheme(value: string | null): value is LegacyDashboardTheme { - return value === "light" || value === "dark" || value === "blue" || value === "red" || value === "green"; -} - -function mapLegacyTheme(theme: LegacyDashboardTheme): { - mode: DashboardMode; - accent: DashboardAccent; - background: DashboardBackground; -} { - switch (theme) { - case "light": - return { mode: "light", accent: "slate", background: "slate" }; - case "dark": - return { mode: "dark", accent: "slate", background: "slate" }; - case "blue": - return { mode: "dark", accent: "blue", background: "ocean" }; - case "red": - return { mode: "dark", accent: "red", background: "wine" }; - case "green": - return { mode: "dark", accent: "green", background: "forest" }; - } -} - -function readStoredAppearance(): { mode: DashboardMode; accent: DashboardAccent; background: DashboardBackground } { - if (typeof window === "undefined") return { mode: "light", accent: "slate", background: "slate" }; - try { - const storedMode = window.localStorage.getItem(modeStorageKey); - const storedAccent = window.localStorage.getItem(accentStorageKey); - const storedBackground = window.localStorage.getItem(backgroundStorageKey); - if (isDashboardMode(storedMode) && isDashboardAccent(storedAccent) && isDashboardBackground(storedBackground)) { - return { mode: storedMode, accent: storedAccent, background: storedBackground }; - } - const legacyTheme = window.localStorage.getItem(legacyThemeStorageKey); - if (isLegacyTheme(legacyTheme)) { - return mapLegacyTheme(legacyTheme); - } - } catch { - // ignore storage access errors - } - return { mode: "light", accent: "slate", background: "slate" }; -} function asErrorMessage(payload: unknown, fallback: string): string { if (payload && typeof payload === "object" && "error" in payload) { @@ -319,7 +243,11 @@ export function listSkillPublishCandidates(skills: Skill[], selectedSkillIds: Se .sort((a, b) => a.skillName.localeCompare(b.skillName) || a.sourceName.localeCompare(b.sourceName)); } -export function InstallerDashboard(): JSX.Element { +export interface InstallerDashboardProps { + windowRole?: DashboardWindowRole; +} + +export function InstallerDashboard({ windowRole = "main" }: InstallerDashboardProps): JSX.Element { const [sources, setSources] = useState([]); const [skills, setSkills] = useState([]); const [selectedSkills, setSelectedSkills] = useState>(new Set()); @@ -363,10 +291,6 @@ export function InstallerDashboard(): JSX.Element { const [skillValidationResult, setSkillValidationResult] = useState(null); const [skillPublishResult, setSkillPublishResult] = useState(null); const [activeTab, setActiveTab] = useState("skills"); - const [appearanceMode, setAppearanceMode] = useState(() => readStoredAppearance().mode); - const [appearanceAccent, setAppearanceAccent] = useState(() => readStoredAppearance().accent); - const [appearanceBackground, setAppearanceBackground] = useState(() => readStoredAppearance().background); - const [appearanceOpen, setAppearanceOpen] = useState(false); const [sourceFilter, setSourceFilter] = useState("all"); const [scopeFilter, setScopeFilter] = useState("all"); const [categoryFilter, setCategoryFilter] = useState("all"); @@ -380,8 +304,14 @@ export function InstallerDashboard(): JSX.Element { const [publishOriginSourceId, setPublishOriginSourceId] = useState(undefined); const [appUpdate, setAppUpdate] = useState(null); const [appUpdateBusy, setAppUpdateBusy] = useState(false); - const appearancePanelRef = useRef(null); - const appearanceTriggerRef = useRef(null); + const { + mode: appearanceMode, + accent: appearanceAccent, + background: appearanceBackground, + setMode: setAppearanceMode, + setAccent: setAppearanceAccent, + setBackground: setAppearanceBackground, + } = useDashboardAppearance(windowRole); const selectedTargetList = useMemo(() => Array.from(targets).sort(), [targets]); const selectedHookTargetList = useMemo( @@ -1319,6 +1249,15 @@ export function InstallerDashboard(): JSX.Element { } } + async function handleOpenSettingsWindow(): Promise { + setError(""); + try { + await openSettingsWindow(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + useEffect(() => { fetchDiscoveredTargets().catch((err) => setError(err instanceof Error ? err.message : String(err))); fetchSources() @@ -1331,6 +1270,10 @@ export function InstallerDashboard(): JSX.Element { }, []); useEffect(() => { + if (windowRole !== "main") { + return; + } + const stopRealtime = startRealtimeClient({ onStatusChange: setRealtimeStatus, onEvent(event) { @@ -1341,7 +1284,7 @@ export function InstallerDashboard(): JSX.Element { return () => { stopRealtime(); }; - }, []); + }, [windowRole]); useEffect(() => { if (sources.length === 0) { @@ -1469,44 +1412,6 @@ export function InstallerDashboard(): JSX.Element { }); }, [hooks.length, hookById]); - useEffect(() => { - document.body.dataset.mode = appearanceMode; - document.body.dataset.accent = appearanceAccent; - document.body.dataset.background = appearanceBackground; - document.documentElement.dataset.mode = appearanceMode; - document.documentElement.dataset.accent = appearanceAccent; - document.documentElement.dataset.background = appearanceBackground; - try { - window.localStorage.setItem(modeStorageKey, appearanceMode); - window.localStorage.setItem(accentStorageKey, appearanceAccent); - window.localStorage.setItem(backgroundStorageKey, appearanceBackground); - } catch { - // ignore storage access errors - } - }, [appearanceMode, appearanceAccent, appearanceBackground]); - - useEffect(() => { - if (!appearanceOpen) return; - const onPointerDown = (event: MouseEvent): void => { - const target = event.target; - if (!(target instanceof Node)) return; - if (appearancePanelRef.current?.contains(target)) return; - if (appearanceTriggerRef.current?.contains(target)) return; - setAppearanceOpen(false); - }; - const onKeyDown = (event: KeyboardEvent): void => { - if (event.key === "Escape") { - setAppearanceOpen(false); - } - }; - window.addEventListener("mousedown", onPointerDown); - window.addEventListener("keydown", onKeyDown); - return () => { - window.removeEventListener("mousedown", onPointerDown); - window.removeEventListener("keydown", onKeyDown); - }; - }, [appearanceOpen]); - const totalSkills = skills.length; const filteredSkillsCount = visibleSkills.length; const selectedSkillCount = selectedSkills.size; @@ -1633,6 +1538,258 @@ export function InstallerDashboard(): JSX.Element { }; }, [appUpdate, appUpdateBusy]); + if (windowRole === "settings") { + return ( +
+
+
+
+

ICA DESKTOP SETTINGS

+

Settings

+

+ Keep appearance, repository preferences, and installer defaults in a dedicated Settings window instead of the main workspace shell. +

+
+
+ {sources.length} sources + {selectedTargetList.length} active targets + {scope === "project" ? "Project scope" : "User scope"} +
+
+
+ + {error && ( +
+ Action needed: {error} +
+ )} + {catalogLoading && ( +
+
+ Loading skills catalog + {Math.round(catalogLoadingProgress)}% +
+
{catalogLoadingMessage || "Working…"}
+
+ )} + +
+ + +
+

Repository Management

+

Attach repositories once; ICA syncs skills and hooks mirrors automatically.

+
{sources.length} configured
+
+ {sources.map((source) => ( +
+ {source.id} + {source.repoUrl} + + roots: {source.skillsRoot || "(no /skills)"} / {source.hooksRoot || "(no /hooks)"} + + + publish: {source.publishDefaultMode} / base {source.defaultBaseBranch || "main"} / provider {source.providerHint} + + {source.lastSyncAt ? `synced ${new Date(source.lastSyncAt).toLocaleString()}` : "never synced"} + {source.lastError && {source.lastError}} +
+ + + {source.removable && ( + + )} +
+
+ ))} +
+ +

Source Publish Settings

+ Selected Source + + Default Publish Mode + + Default Base Branch + setSourceDefaultBaseBranch(event.target.value)} + /> + Provider Hint + + + + +

Add Repository

+ Source Name + setSourceName(event.target.value)} + /> + Repository URL + setSourceRepoUrl(event.target.value)} + /> +
+ + +
+ {sourceTransport === "https" && ( + <> + PAT / API key + setSourceToken(event.target.value)} + /> + + )} + Default Publish Mode (new source) + + Default Base Branch (new source) + setSourceDefaultBaseBranch(event.target.value)} + /> + Provider Hint (new source) + + + + +
+ +
+

Installer Settings

+

Tune targets, scope, and install mode without mixing these desktop defaults into the main workspace tabs.

+

Targets: {selectedTargetList.join(", ")}

+
+ {allTargets.map((target) => ( + + ))} +
+ +

Scope

+
+ + +
+ {scope === "project" && ( + <> + setProjectPath(event.target.value)} + /> + + + + )} + +

Install Mode

+
+ + +
+
+
+
+ ); + } + return (
@@ -1710,7 +1867,6 @@ export function InstallerDashboard(): JSX.Element { type="button" disabled={busy} onClick={() => { - setActiveTab("settings"); void pickProjectPath(); }} > @@ -1721,7 +1877,6 @@ export function InstallerDashboard(): JSX.Element { type="button" disabled={busy || !trimmedProjectPath} onClick={() => { - setActiveTab("settings"); void mountProjectInContainer(); }} > @@ -1743,11 +1898,10 @@ export function InstallerDashboard(): JSX.Element { type="button" disabled={busy} onClick={() => { - setActiveTab("settings"); - void refreshSource(); + void handleOpenSettingsWindow(); }} > - Refresh repositories + Open Settings
@@ -1840,15 +1994,6 @@ export function InstallerDashboard(): JSX.Element { > Skills - - {appearanceOpen && ( -
-
-
- Theme -
- {modeOptions.map((option) => ( - - ))} -
-
-
- Accent -
- {accentOptions.map((option) => ( - - ))} -
-
-
-
- Background -
- {backgroundOptions.map((option) => ( - - ))} -
-
-
- )}
@@ -2373,210 +2452,6 @@ export function InstallerDashboard(): JSX.Element { )} - {activeTab === "settings" && ( -
-
-

Repository Management

-

Attach repositories once; ICA syncs skills and hooks mirrors automatically.

-
{sources.length} configured
-
- {sources.map((source) => ( -
- {source.id} - {source.repoUrl} - - roots: {source.skillsRoot || "(no /skills)"} / {source.hooksRoot || "(no /hooks)"} - - - publish: {source.publishDefaultMode} / base {source.defaultBaseBranch || "main"} / provider {source.providerHint} - - {source.lastSyncAt ? `synced ${new Date(source.lastSyncAt).toLocaleString()}` : "never synced"} - {source.lastError && {source.lastError}} -
- - - {source.removable && ( - - )} -
-
- ))} -
- -

Source Publish Settings

- Selected Source - - Default Publish Mode - - Default Base Branch - setSourceDefaultBaseBranch(event.target.value)} - /> - Provider Hint - - - - -

Add Repository

- Source Name - setSourceName(event.target.value)} - /> - Repository URL - setSourceRepoUrl(event.target.value)} - /> -
- - -
- {sourceTransport === "https" && ( - <> - PAT / API key - setSourceToken(event.target.value)} - /> - - )} - Default Publish Mode (new source) - - Default Base Branch (new source) - setSourceDefaultBaseBranch(event.target.value)} - /> - Provider Hint (new source) - - - - -
- -
-

Installer Settings

-

Tune targets, scope, and install mode for each operation.

-

Targets: {selectedTargetList.join(", ")}

-
- {allTargets.map((target) => ( - - ))} -
- -

Scope

-
- - -
- {scope === "project" && ( - <> - setProjectPath(event.target.value)} - /> - - - - )} - -

Install Mode

-
- - -
-
-
- )} - {activeTab === "state" && (
diff --git a/src/installer-dashboard/web/src/appearance.ts b/src/installer-dashboard/web/src/appearance.ts new file mode 100644 index 0000000..f3ebf7e --- /dev/null +++ b/src/installer-dashboard/web/src/appearance.ts @@ -0,0 +1,173 @@ +import { useEffect, useState } from "react"; +import type { DashboardWindowRole } from "./window-role"; + +export type DashboardMode = "light" | "dark"; +export type DashboardAccent = "slate" | "blue" | "red" | "green" | "amber"; +export type DashboardBackground = "slate" | "ocean" | "sand" | "forest" | "wine"; +type LegacyDashboardTheme = "light" | "dark" | "blue" | "red" | "green"; + +export interface DashboardAppearance { + mode: DashboardMode; + accent: DashboardAccent; + background: DashboardBackground; +} + +export const modeOptions: Array<{ id: DashboardMode; label: string }> = [ + { id: "light", label: "Light" }, + { id: "dark", label: "Dark" }, +]; + +export const accentOptions: Array<{ id: DashboardAccent; label: string }> = [ + { id: "slate", label: "Slate" }, + { id: "blue", label: "Blue" }, + { id: "red", label: "Red" }, + { id: "green", label: "Green" }, + { id: "amber", label: "Amber" }, +]; + +export const backgroundOptions: Array<{ id: DashboardBackground; label: string }> = [ + { id: "slate", label: "Slate" }, + { id: "ocean", label: "Ocean" }, + { id: "sand", label: "Sand" }, + { id: "forest", label: "Forest" }, + { id: "wine", label: "Wine" }, +]; + +const modeStorageKey = "ica.dashboard.mode"; +const accentStorageKey = "ica.dashboard.accent"; +const backgroundStorageKey = "ica.dashboard.background"; +const legacyThemeStorageKey = "ica.dashboard.theme"; + +function isDashboardMode(value: string | null): value is DashboardMode { + return value === "light" || value === "dark"; +} + +function isDashboardAccent(value: string | null): value is DashboardAccent { + return value === "slate" || value === "blue" || value === "red" || value === "green" || value === "amber"; +} + +function isDashboardBackground(value: string | null): value is DashboardBackground { + return value === "slate" || value === "ocean" || value === "sand" || value === "forest" || value === "wine"; +} + +function isLegacyTheme(value: string | null): value is LegacyDashboardTheme { + return value === "light" || value === "dark" || value === "blue" || value === "red" || value === "green"; +} + +function mapLegacyTheme(theme: LegacyDashboardTheme): DashboardAppearance { + switch (theme) { + case "light": + return { mode: "light", accent: "slate", background: "slate" }; + case "dark": + return { mode: "dark", accent: "slate", background: "slate" }; + case "blue": + return { mode: "dark", accent: "blue", background: "ocean" }; + case "red": + return { mode: "dark", accent: "red", background: "wine" }; + case "green": + return { mode: "dark", accent: "green", background: "forest" }; + } +} + +export function readStoredAppearance(): DashboardAppearance { + if (typeof window === "undefined") { + return { mode: "light", accent: "slate", background: "slate" }; + } + + try { + const storedMode = window.localStorage.getItem(modeStorageKey); + const storedAccent = window.localStorage.getItem(accentStorageKey); + const storedBackground = window.localStorage.getItem(backgroundStorageKey); + if (isDashboardMode(storedMode) && isDashboardAccent(storedAccent) && isDashboardBackground(storedBackground)) { + return { mode: storedMode, accent: storedAccent, background: storedBackground }; + } + + const legacyTheme = window.localStorage.getItem(legacyThemeStorageKey); + if (isLegacyTheme(legacyTheme)) { + return mapLegacyTheme(legacyTheme); + } + } catch { + // Ignore storage access errors. + } + + return { mode: "light", accent: "slate", background: "slate" }; +} + +function writeStoredAppearance(appearance: DashboardAppearance): void { + if (typeof window === "undefined") { + return; + } + + try { + window.localStorage.setItem(modeStorageKey, appearance.mode); + window.localStorage.setItem(accentStorageKey, appearance.accent); + window.localStorage.setItem(backgroundStorageKey, appearance.background); + } catch { + // Ignore storage access errors. + } +} + +export function applyAppearanceToDocument(appearance: DashboardAppearance): void { + if (typeof document === "undefined") { + return; + } + + document.body.dataset.mode = appearance.mode; + document.body.dataset.accent = appearance.accent; + document.body.dataset.background = appearance.background; + document.documentElement.dataset.mode = appearance.mode; + document.documentElement.dataset.accent = appearance.accent; + document.documentElement.dataset.background = appearance.background; +} + +export function useDashboardAppearance(windowRole: DashboardWindowRole): DashboardAppearance & { + canEditAppearance: boolean; + setMode(mode: DashboardMode): void; + setAccent(accent: DashboardAccent): void; + setBackground(background: DashboardBackground): void; +} { + const [appearance, setAppearance] = useState(() => readStoredAppearance()); + + useEffect(() => { + applyAppearanceToDocument(appearance); + }, [appearance]); + + useEffect(() => { + if (windowRole !== "settings") { + return; + } + writeStoredAppearance(appearance); + }, [appearance, windowRole]); + + useEffect(() => { + if (windowRole !== "main" || typeof window === "undefined") { + return; + } + + const onStorage = (event: StorageEvent): void => { + if (event.key && event.key !== modeStorageKey && event.key !== accentStorageKey && event.key !== backgroundStorageKey && event.key !== legacyThemeStorageKey) { + return; + } + setAppearance(readStoredAppearance()); + }; + + window.addEventListener("storage", onStorage); + return () => { + window.removeEventListener("storage", onStorage); + }; + }, [windowRole]); + + return { + ...appearance, + canEditAppearance: windowRole === "settings", + setMode(mode) { + setAppearance((current) => (current.mode === mode ? current : { ...current, mode })); + }, + setAccent(accent) { + setAppearance((current) => (current.accent === accent ? current : { ...current, accent })); + }, + setBackground(background) { + setAppearance((current) => (current.background === background ? current : { ...current, background })); + }, + }; +} diff --git a/src/installer-dashboard/web/src/control-plane-client.ts b/src/installer-dashboard/web/src/control-plane-client.ts index d42196f..51413bd 100644 --- a/src/installer-dashboard/web/src/control-plane-client.ts +++ b/src/installer-dashboard/web/src/control-plane-client.ts @@ -197,6 +197,14 @@ export async function pickPublishDirectory(initialPath?: string): Promise<{ path return { path: payload.path }; } +export async function openSettingsWindow(): Promise { + const desktopBridge = getDesktopBridge(); + if (!desktopBridge) { + throw createHostBridgeUnavailableError(); + } + await desktopBridge.openSettingsWindow(); +} + export async function reportRendererFailure(payload: DesktopHostFailureReport): Promise { const desktopBridge = getDesktopBridge(); if (!desktopBridge) { diff --git a/src/installer-dashboard/web/src/styles.css b/src/installer-dashboard/web/src/styles.css index a0699b0..33fff67 100644 --- a/src/installer-dashboard/web/src/styles.css +++ b/src/installer-dashboard/web/src/styles.css @@ -689,7 +689,6 @@ input[type="radio"]:focus-visible { display: flex; align-items: center; gap: var(--space-2); - position: relative; } .tab-btn { @@ -705,28 +704,6 @@ input[type="radio"]:focus-visible { min-height: 0; } -.appearance-toggle { - display: inline-flex; - align-items: center; - gap: var(--space-2); - padding: 0.34rem 0.64rem; - border-radius: 8px; - min-height: 2rem; -} - -.appearance-toggle-icon { - width: 0.92rem; - height: 0.92rem; - opacity: 0.84; -} - -.appearance-toggle.is-active { - border-color: var(--chip-active-border); - background: var(--accent-soft); - color: var(--accent-ink); - box-shadow: var(--active-glow); -} - .tab-btn:hover { background: transparent; color: var(--text); @@ -875,20 +852,6 @@ input[type="radio"]:focus-visible { color: #b67186; } -.appearance-popover { - position: absolute; - top: calc(100% + var(--space-2)); - right: 0; - z-index: 30; - width: min(30rem, calc(100vw - 2rem)); - border: 1px solid color-mix(in srgb, var(--line) 84%, transparent); - border-radius: 12px; - background: color-mix(in srgb, var(--surface) 97%, transparent); - box-shadow: 0 10px 22px rgba(8, 17, 32, 0.12); - padding: var(--space-3); - backdrop-filter: blur(12px); -} - .theme-btn.is-active { border-color: var(--chip-active-border); background: var(--accent-soft); @@ -1727,6 +1690,14 @@ input[type="radio"]:focus-visible { align-items: start; } +.settings-window-shell { + gap: var(--space-6); +} + +.settings-window-header { + margin-bottom: var(--space-2); +} + .settings-grid .panel-settings { display: block; } @@ -1967,9 +1938,6 @@ pre { gap: var(--space-1); } - .appearance-popover { - width: min(32rem, calc(100vw - 2rem)); - } } @media (max-width: 760px) { @@ -2010,11 +1978,6 @@ pre { .toolbar-actions { justify-content: flex-start; - position: static; - } - - .appearance-toggle { - width: fit-content; } .theme-group { @@ -2043,12 +2006,6 @@ pre { justify-content: flex-start; } - .appearance-popover { - position: static; - width: 100%; - margin-top: var(--space-3); - } - .panel-spacious { padding: var(--space-5); } diff --git a/src/installer-dashboard/web/src/window-role.ts b/src/installer-dashboard/web/src/window-role.ts new file mode 100644 index 0000000..b5ed451 --- /dev/null +++ b/src/installer-dashboard/web/src/window-role.ts @@ -0,0 +1,6 @@ +export type DashboardWindowRole = "main" | "settings"; + +export function resolveDashboardWindowRole(search: string = typeof window === "undefined" ? "" : window.location.search): DashboardWindowRole { + const params = new URLSearchParams(search); + return params.get("windowRole") === "settings" ? "settings" : "main"; +} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index fc5c69d..72abc1e 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -40,6 +40,7 @@ declare module "electron" { loadURL(url: string): Promise; show(): void; focus(): void; + isDestroyed(): boolean; webContents: WebContents; } diff --git a/tests/installer/control-plane-client.test.ts b/tests/installer/control-plane-client.test.ts index 3bd4384..f65c371 100644 --- a/tests/installer/control-plane-client.test.ts +++ b/tests/installer/control-plane-client.test.ts @@ -1,13 +1,14 @@ import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; -import { controlPlaneFetch, startControlPlaneRealtimeClient } from "../../src/installer-dashboard/web/src/control-plane-client"; +import { controlPlaneFetch, openSettingsWindow, startControlPlaneRealtimeClient } from "../../src/installer-dashboard/web/src/control-plane-client"; import type { RealtimeEvent } from "../../src/desktop-electron/bridge"; type DesktopWindow = Window & { icaDesktop?: { request: (channel: "control-plane.request", payload: unknown) => Promise<{ status: number; body: unknown }>; subscribeRealtime: (listener: (event: RealtimeEvent) => void) => () => void; + openSettingsWindow?: () => Promise; }; }; @@ -107,6 +108,35 @@ test("desktop-mode renderer must fail explicitly instead of silently falling bac assert.doesNotMatch(clientSource, /if \(!desktopBridge\) {\s*return fetch\(pathname, init\);\s*}/m); }); +test("openSettingsWindow routes through the desktop bridge when available", async (t) => { + let openCalls = 0; + + setDesktopWindow({ + icaDesktop: { + async request() { + return { + status: 200, + body: {}, + }; + }, + subscribeRealtime() { + return () => undefined; + }, + async openSettingsWindow() { + openCalls += 1; + }, + }, + } as unknown as DesktopWindow); + + t.after(() => { + setDesktopWindow(undefined); + }); + + await openSettingsWindow(); + + assert.equal(openCalls, 1); +}); + test("startControlPlaneRealtimeClient consumes realtime events from the desktop bridge", async (t) => { const statuses: string[] = []; const events: RealtimeEvent[] = []; diff --git a/tests/installer/desktop-bridge-red-phase.test.ts b/tests/installer/desktop-bridge-red-phase.test.ts index aa8aa68..4e0ef0a 100644 --- a/tests/installer/desktop-bridge-red-phase.test.ts +++ b/tests/installer/desktop-bridge-red-phase.test.ts @@ -23,6 +23,7 @@ test("desktop bridge contract declares typed native host capabilities for the ha assert.match(bridgeSource, /quitAndInstallAppUpdate\(\): Promise<\{ accepted: boolean \}>;/); assert.match(bridgeSource, /pickProjectDirectory\(initialPath\?: string\): Promise<\{ path: string \}>;/); assert.match(bridgeSource, /pickPublishDirectory\(initialPath\?: string\): Promise<\{ path: string \}>;/); + assert.match(bridgeSource, /openSettingsWindow\(\): Promise;/); assert.match(bridgeSource, /getRuntimeInfo\(\): Promise;/); assert.match(bridgeSource, /reportRendererFailure\(payload: DesktopHostFailureReport\): Promise;/); }); diff --git a/tests/installer/desktop-settings-window-red-phase.test.ts b/tests/installer/desktop-settings-window-red-phase.test.ts new file mode 100644 index 0000000..411670c --- /dev/null +++ b/tests/installer/desktop-settings-window-red-phase.test.ts @@ -0,0 +1,60 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +function readWorkspaceFile(relativePath: string): string { + return fs.readFileSync(path.resolve(process.cwd(), relativePath), "utf8"); +} + +test("RED: main window no longer owns Settings as a dashboard tab", () => { + const ui = readWorkspaceFile("src/installer-dashboard/web/src/InstallerDashboard.tsx"); + + assert.doesNotMatch( + ui, + /type DashboardTab = "skills" \| "hooks" \| "settings" \| "state"/, + "Settings ownership work should remove the Settings tab from the main-window navigation model.", + ); + assert.doesNotMatch( + ui, + /activeTab === "settings"/, + "Settings ownership work should remove active-tab checks for a main-window Settings route.", + ); +}); + +test("RED: main window removes inline appearance controls and replaces them with a Settings launcher", () => { + const ui = readWorkspaceFile("src/installer-dashboard/web/src/InstallerDashboard.tsx"); + + assert.doesNotMatch( + ui, + /appearance-toggle|appearance-popover|theme-btn/, + "Theme controls should move out of the main window and into the dedicated Settings window.", + ); + assert.match( + ui, + />\s*Open Settings\s* { + const bridgeSource = readWorkspaceFile("src/desktop-electron/bridge.ts"); + const clientSource = readWorkspaceFile("src/installer-dashboard/web/src/control-plane-client.ts"); + const preloadSource = readWorkspaceFile("src/desktop-electron/preload.ts"); + + assert.match(bridgeSource, /openSettingsWindow\(\): Promise;/); + assert.match(clientSource, /export async function openSettingsWindow\(\): Promise/); + assert.match(preloadSource, /openSettingsWindow\(\)/); +}); + +test("RED: electron main process owns a reusable dedicated Settings window", () => { + const mainSource = readWorkspaceFile("src/desktop-electron/main.ts"); + + assert.match(mainSource, /settingsWindow/i, "Electron main should track a dedicated Settings window handle."); + assert.match(mainSource, /createSettingsWindow/, "Electron main should create a dedicated Settings window."); + assert.match( + mainSource, + /buildDesktopWindowUrl\(startUrl, "settings"\)|searchParams\.set\("windowRole", windowRole\)/, + "Settings window should load the shared renderer bundle with an explicit settings window role.", + ); +}); diff --git a/tests/installer/desktop-shell-frame-red-phase.test.ts b/tests/installer/desktop-shell-frame-red-phase.test.ts index db4e079..c211168 100644 --- a/tests/installer/desktop-shell-frame-red-phase.test.ts +++ b/tests/installer/desktop-shell-frame-red-phase.test.ts @@ -17,7 +17,7 @@ test("RED: app shell stops using dashboard-main as the primary desktop wrapper", ); assert.match( app, - /className="desktop-shell-app"/, + /desktop-shell-app/, "Desktop shell work should introduce a desktop-shell-app wrapper for the main window.", ); });