openpencil/apps/desktop/preload.ts
Kayshen-X a7d73ebb62 feat(ai): pencil-style agentic design tool-loop, multi-chat tabs, #27 panel restyle
Built-in design generation now runs as an agentic MCP tool-loop (reusing the
agent-rs BuiltInProvider), gated behind OPENPENCIL_DESIGN_AGENT_LOOP / the
Settings experimental toggle; the orchestrator stays the default.

- design-agent system prompt + in-process design toolset (parity-locked with
  the MCP surface) + flag-gated Intent::Design routing
- spawn_agents execution as sequential sub-loops + live creation-mode badges
  (per-agent glow + 'N/M designing...' header)
- new MCP tools: get_guidelines, ToolSearch, get_screenshot, get_editor_state,
  export_nodes, spawn_agents; style-guide local audit
- #27 AI panel restyle: rounded tool cards + green check-rings, gray user
  bubbles, model-pill bottom toolbar, header, empty-state pills, the
  PARALLEL AGENTS (agent_team_size) 1x-6x chip dropdown
- multi-chat tabs: ChatSessions model (Deref-to-active) + tab row UI
  (switch / close / + / Cmd+T) with each run bound to its tab

Large checkpoint commit spanning the working tree (Rust shell crates).
2026-07-02 21:21:06 +08:00

426 lines
15 KiB
TypeScript

import { contextBridge, ipcRenderer, webUtils, type IpcRendererEvent } from 'electron';
export type UpdaterStatus =
| 'disabled'
| 'idle'
| 'checking'
| 'available'
| 'downloading'
| 'downloaded'
| 'not-available'
| 'error';
export interface UpdaterState {
status: UpdaterStatus;
currentVersion: string;
latestVersion?: string;
downloadProgress?: number;
releaseDate?: string;
error?: string;
}
// ---- Git API types (Phase 2a) ------------------------------------------------
// These mirror the engine's RepoOpenInfo / StatusInfo / CommitMeta / BranchInfo
// shapes so the renderer can type its IPC calls. The marker-based GitError
// rehydration is implemented on the renderer side in Phase 3.
export interface GitCandidateFileInfo {
path: string;
relativePath: string;
milestoneCount: number;
autosaveCount: number;
lastCommitAt: number | null;
lastCommitMessage: string | null;
}
export interface GitRepoOpenInfo {
repoId: string;
mode: 'single-file' | 'folder';
rootPath: string;
gitdir: string;
engineKind: 'iso' | 'sys';
trackedFilePath: string | null;
candidates: GitCandidateFileInfo[];
}
export interface GitConflictBag {
nodeConflicts: Array<{
id: string;
pageId: string | null;
nodeId: string;
reason:
| 'both-modified-same-field'
| 'modify-vs-delete'
| 'add-vs-add-different'
| 'reparent-conflict';
base: unknown;
ours: unknown;
theirs: unknown;
}>;
docFieldConflicts: Array<{
id: string;
field: string;
path: string;
base: unknown;
ours: unknown;
theirs: unknown;
}>;
}
export type GitConflictResolution =
| { kind: 'ours' }
| { kind: 'theirs' }
| { kind: 'manual-node'; node: unknown }
| { kind: 'manual-field'; value: unknown };
export interface GitStatusInfo {
branch: string;
trackedFilePath: string | null;
workingDirty: boolean;
otherFilesDirty: number;
otherFilesPaths: string[];
ahead: number;
behind: number;
mergeInProgress: boolean;
unresolvedFiles: string[];
conflicts: GitConflictBag | null;
}
export interface GitCommitMeta {
hash: string;
parentHashes: string[];
message: string;
author: { name: string; email: string; timestamp: number };
kind: 'milestone' | 'autosave';
}
export interface GitBranchInfo {
name: string;
isCurrent: boolean;
ahead: number;
behind: number;
lastCommit: { hash: string; message: string; timestamp: number } | null;
}
/**
* Phase 6a: renderer-visible remote metadata for the single 'origin' remote.
* Mirrors apps/web/src/services/git-types.ts so the renderer can type its
* IPC calls without importing from the desktop side.
*/
export interface GitRemoteInfo {
name: 'origin';
url: string | null;
host: string | null;
}
export interface GitAPI {
detect: (filePath: string) => Promise<{ mode: 'none' } | GitRepoOpenInfo>;
init: (filePath: string) => Promise<GitRepoOpenInfo>;
open: (repoPath: string, currentFilePath?: string) => Promise<GitRepoOpenInfo>;
bindTrackedFile: (repoId: string, filePath: string) => Promise<{ trackedFilePath: string }>;
listCandidates: (repoId: string) => Promise<GitCandidateFileInfo[]>;
close: (repoId: string) => Promise<void>;
status: (repoId: string) => Promise<GitStatusInfo>;
log: (
repoId: string,
opts: { ref: 'main' | 'autosaves' | string; limit: number },
) => Promise<GitCommitMeta[]>;
commit: (
repoId: string,
opts: {
kind: 'milestone' | 'autosave';
message: string;
author: { name: string; email: string };
},
) => Promise<{ hash: string }>;
restore: (repoId: string, commitHash: string) => Promise<void>;
promote: (
repoId: string,
autosaveHash: string,
message: string,
author: { name: string; email: string },
) => Promise<{ hash: string }>;
branchList: (repoId: string) => Promise<GitBranchInfo[]>;
branchCreate: (repoId: string, opts: { name: string; fromCommit?: string }) => Promise<void>;
branchSwitch: (repoId: string, name: string) => Promise<void>;
branchDelete: (repoId: string, name: string, opts?: { force?: boolean }) => Promise<void>;
// ---- Phase 2b: remote ops ----
clone: (opts: {
url: string;
dest: string;
auth?: { kind: 'token'; username: string; token: string } | { kind: 'ssh'; keyId: string };
}) => Promise<GitRepoOpenInfo>;
fetch: (
repoId: string,
auth?: { kind: 'token'; username: string; token: string } | { kind: 'ssh'; keyId: string },
) => Promise<{ ahead: number; behind: number }>;
pull: (
repoId: string,
auth?: { kind: 'token'; username: string; token: string } | { kind: 'ssh'; keyId: string },
) => Promise<{
result: 'fast-forward' | 'merge' | 'conflict' | 'conflict-non-op';
conflicts?: GitConflictBag;
}>;
push: (
repoId: string,
auth?: { kind: 'token'; username: string; token: string } | { kind: 'ssh'; keyId: string },
) => Promise<{ result: 'ok' }>;
// ---- Phase 2b: auth ----
authStore: (
host: string,
creds: { kind: 'token'; username: string; token: string } | { kind: 'ssh'; keyId: string },
) => Promise<void>;
authGet: (
host: string,
) => Promise<
{ kind: 'token'; username: string; token: string } | { kind: 'ssh'; keyId: string } | null
>;
authClear: (host: string) => Promise<void>;
// ---- Phase 2b: ssh keys (privateKeyPath stripped) ----
sshListKeys: () => Promise<
Array<{
id: string;
host: string;
publicKey: string;
fingerprint: string;
comment: string;
}>
>;
sshGenerateKey: (opts: { host: string; comment: string }) => Promise<{
id: string;
host: string;
publicKey: string;
fingerprint: string;
comment: string;
}>;
sshImportKey: (opts: { privateKeyPath: string; host: string }) => Promise<{
id: string;
host: string;
publicKey: string;
fingerprint: string;
comment: string;
}>;
sshDeleteKey: (keyId: string) => Promise<void>;
// ---- Phase 2c: merge orchestration ----
diff: (
repoId: string,
fromCommit: string,
toCommit: string,
) => Promise<{
summary: {
framesChanged: number;
nodesAdded: number;
nodesRemoved: number;
nodesModified: number;
};
patches: unknown[];
}>;
branchMerge: (
repoId: string,
fromBranch: string,
) => Promise<{
result: 'fast-forward' | 'merge' | 'conflict' | 'conflict-non-op';
conflicts?: GitConflictBag;
}>;
resolveConflict: (
repoId: string,
conflictId: string,
choice: GitConflictResolution,
) => Promise<void>;
applyMerge: (repoId: string) => Promise<{ hash: string; noop: boolean }>;
abortMerge: (repoId: string) => Promise<void>;
// Phase 4a: author identity probe (system git config)
getSystemAuthor: () => Promise<{ name: string; email: string } | null>;
// Phase 6a: remote metadata + config (no network)
remoteGet: (repoId: string) => Promise<GitRemoteInfo>;
remoteSet: (repoId: string, url: string | null) => Promise<GitRemoteInfo>;
}
export interface ElectronAPI {
isElectron: true;
openFile: () => Promise<{ filePath: string; content: string } | null>;
openImageFile: () => Promise<{ filePath: string; name: string; content: string | null } | null>;
openDirectory: () => Promise<string | null>;
saveFile: (content: string, defaultPath?: string) => Promise<string | null>;
saveToPath: (filePath: string, content: string) => Promise<string>;
onMenuAction: (callback: (action: string) => void) => () => void;
onOpenFile: (callback: (filePath: string) => void) => () => void;
readFile: (filePath: string) => Promise<{ filePath: string; content: string } | null>;
getPendingFile: () => Promise<string | null>;
getLogDir: () => Promise<string>;
setTheme: (theme: 'dark' | 'light', colors?: { bg: string; fg: string }) => void;
getPreferences: () => Promise<Record<string, string>>;
setPreference: (key: string, value: string) => Promise<void>;
removePreference: (key: string) => Promise<void>;
confirmClose: () => void;
confirmUnsavedChanges: (payload: {
message: string;
detail?: string;
yesLabel: string;
noLabel: string;
cancelLabel: string;
}) => Promise<'save' | 'discard' | 'cancel'>;
syncRecentFiles: (files: Array<{ fileName: string; filePath: string }>) => void;
/** Resolve the absolute filesystem path of a File object obtained from drag-and-drop. */
getPathForFile: (file: File) => string | null;
updater: {
getState: () => Promise<UpdaterState>;
checkForUpdates: () => Promise<UpdaterState>;
quitAndInstall: () => Promise<boolean>;
getAutoCheck: () => Promise<boolean>;
setAutoCheck: (enabled: boolean) => Promise<boolean>;
onStateChange: (callback: (state: UpdaterState) => void) => () => void;
};
git: GitAPI;
}
const api: ElectronAPI = {
isElectron: true,
openFile: () => ipcRenderer.invoke('dialog:openFile'),
openImageFile: () => ipcRenderer.invoke('dialog:openImageFile'),
openDirectory: () => ipcRenderer.invoke('dialog:openDirectory'),
saveFile: (content: string, defaultPath?: string) =>
ipcRenderer.invoke('dialog:saveFile', { content, defaultPath }),
saveToPath: (filePath: string, content: string) =>
ipcRenderer.invoke('dialog:saveToPath', { filePath, content }),
setTheme: (theme: 'dark' | 'light', colors?: { bg: string; fg: string }) =>
ipcRenderer.invoke('theme:set', theme, colors),
getPreferences: () => ipcRenderer.invoke('prefs:getAll'),
setPreference: (key: string, value: string) => ipcRenderer.invoke('prefs:set', key, value),
removePreference: (key: string) => ipcRenderer.invoke('prefs:remove', key),
onMenuAction: (callback: (action: string) => void) => {
const listener = (_event: IpcRendererEvent, action: string) => {
callback(action);
};
ipcRenderer.on('menu:action', listener);
return () => {
ipcRenderer.removeListener('menu:action', listener);
};
},
onOpenFile: (callback: (filePath: string) => void) => {
const listener = (_event: IpcRendererEvent, filePath: string) => {
callback(filePath);
};
ipcRenderer.on('file:open', listener);
return () => {
ipcRenderer.removeListener('file:open', listener);
};
},
readFile: (filePath: string) => ipcRenderer.invoke('file:read', filePath),
getPendingFile: () => ipcRenderer.invoke('file:getPending'),
syncRecentFiles: (files: Array<{ fileName: string; filePath: string }>) =>
ipcRenderer.send('recent-files:sync', files),
getPathForFile: (file: File) => {
try {
const p = webUtils.getPathForFile(file);
return p && p.length > 0 ? p : null;
} catch {
return null;
}
},
confirmClose: () => ipcRenderer.send('window:confirmClose'),
confirmUnsavedChanges: (payload) => ipcRenderer.invoke('dialog:confirmUnsavedChanges', payload),
getLogDir: () => ipcRenderer.invoke('log:getDir'),
updater: {
getState: () => ipcRenderer.invoke('updater:getState'),
checkForUpdates: () => ipcRenderer.invoke('updater:checkForUpdates'),
quitAndInstall: () => ipcRenderer.invoke('updater:quitAndInstall'),
getAutoCheck: () => ipcRenderer.invoke('updater:getAutoCheck'),
setAutoCheck: (enabled: boolean) => ipcRenderer.invoke('updater:setAutoCheck', enabled),
onStateChange: (callback: (state: UpdaterState) => void) => {
const listener = (_event: IpcRendererEvent, state: UpdaterState) => {
callback(state);
};
ipcRenderer.on('updater:state', listener);
return () => {
ipcRenderer.removeListener('updater:state', listener);
};
},
},
git: {
detect: (filePath: string) => ipcRenderer.invoke('git:detect', filePath),
init: (filePath: string) => ipcRenderer.invoke('git:init', filePath),
open: (repoPath: string, currentFilePath?: string) =>
ipcRenderer.invoke('git:open', repoPath, currentFilePath),
bindTrackedFile: (repoId: string, filePath: string) =>
ipcRenderer.invoke('git:bindTrackedFile', repoId, filePath),
listCandidates: (repoId: string) => ipcRenderer.invoke('git:listCandidates', repoId),
close: (repoId: string) => ipcRenderer.invoke('git:close', repoId),
status: (repoId: string) => ipcRenderer.invoke('git:status', repoId),
log: (repoId, opts) => ipcRenderer.invoke('git:log', repoId, opts),
commit: (repoId, opts) => ipcRenderer.invoke('git:commit', repoId, opts),
restore: (repoId: string, commitHash: string) =>
ipcRenderer.invoke('git:restore', repoId, commitHash),
promote: (repoId, autosaveHash, message, author) =>
ipcRenderer.invoke('git:promote', repoId, autosaveHash, message, author),
branchList: (repoId: string) => ipcRenderer.invoke('git:branchList', repoId),
branchCreate: (repoId, opts) => ipcRenderer.invoke('git:branchCreate', repoId, opts),
branchSwitch: (repoId: string, name: string) =>
ipcRenderer.invoke('git:branchSwitch', repoId, name),
branchDelete: (repoId: string, name: string, opts?: { force?: boolean }) =>
ipcRenderer.invoke('git:branchDelete', repoId, name, opts),
// Phase 2b: remote ops
clone: (opts) => ipcRenderer.invoke('git:clone', opts),
fetch: (repoId, auth) => ipcRenderer.invoke('git:fetch', repoId, auth),
pull: (repoId, auth) => ipcRenderer.invoke('git:pull', repoId, auth),
push: (repoId, auth) => ipcRenderer.invoke('git:push', repoId, auth),
// Phase 2b: auth
authStore: (host, creds) => ipcRenderer.invoke('git:authStore', host, creds),
authGet: (host) => ipcRenderer.invoke('git:authGet', host),
authClear: (host) => ipcRenderer.invoke('git:authClear', host),
// Phase 2b: ssh keys
sshListKeys: () => ipcRenderer.invoke('git:sshListKeys'),
sshGenerateKey: (opts) => ipcRenderer.invoke('git:sshGenerateKey', opts),
sshImportKey: (opts) => ipcRenderer.invoke('git:sshImportKey', opts),
sshDeleteKey: (keyId) => ipcRenderer.invoke('git:sshDeleteKey', keyId),
// Phase 2c: merge orchestration
diff: (repoId, fromCommit, toCommit) =>
ipcRenderer.invoke('git:diff', repoId, fromCommit, toCommit),
branchMerge: (repoId, fromBranch) => ipcRenderer.invoke('git:branchMerge', repoId, fromBranch),
resolveConflict: (repoId, conflictId, choice) =>
ipcRenderer.invoke('git:resolveConflict', repoId, conflictId, choice),
applyMerge: (repoId) => ipcRenderer.invoke('git:applyMerge', repoId),
abortMerge: (repoId) => ipcRenderer.invoke('git:abortMerge', repoId),
// Phase 4a: author identity probe
getSystemAuthor: () => ipcRenderer.invoke('git:getSystemAuthor'),
// Phase 6a: remote metadata + config
remoteGet: (repoId: string) => ipcRenderer.invoke('git:remoteGet', repoId),
remoteSet: (repoId: string, url: string | null) =>
ipcRenderer.invoke('git:remoteSet', repoId, url),
},
};
contextBridge.exposeInMainWorld('electronAPI', api);