* feat: Implement project management store with project path validation and synchronization - Added `useProjectsStore` for managing projects, including adding, removing, renaming, and validating project paths. - Implemented persistence for projects and active project ID using safe storage. - Introduced synchronization from desktop settings to keep project data consistent. - Enhanced session store to manage sessions by directory and added new methods for session management. - Updated todo store to fetch session todos based on the directory context. - Refactored server code to validate and resolve project directories for various API endpoints. - Added project entry validation and sanitization to ensure data integrity. * feat(settings): migrate legacy project settings and update settings loading logic * feat: enhance project management with directory-aware settings and improved agent/command source handling * feat: enhance session and project management with directory-aware settings and improved configuration refresh logic * feat: enhance project management with worktree manager integration and project directory resolution * feat: enhance agent groups store with project directory resolution and loading logic * feat: add heartbeat management and global wrapping for SSE blocks in agent and chat providers * feat: refactor command and project handling in useCommandsStore - Replaced useDirectoryStore with useProjectsStore to manage project paths. - Introduced getRequestDirectory function to determine the active project directory. - Updated command fetching to respect project-level scoping. - Enhanced error handling and logging for command configuration fetching. - Improved command configuration saving and updating to utilize project directory context. feat: enhance project path normalization in useProjectsStore - Added resolveTildePath function to expand paths starting with ~. - Updated normalizeProjectPath to utilize home directory for path expansion. fix: update permission handling in useSessionStore - Changed Permission type to PermissionRequest for clarity. - Updated respondToPermission method to use requestId instead of permissionId. refactor: improve permission utilities - Introduced types for PermissionAction and PermissionRule. - Enhanced getAgentDefinition and resolveConfigStore functions for better type safety. - Added resolvePermissionAction to streamline permission resolution logic. feat: add agent configuration retrieval endpoint - Implemented new API endpoint to fetch agent configuration based on project directory. - Enhanced getAgentPermissionSource to prioritize project-level permissions. chore: update SDK version in package.json files - Bumped @opencode-ai/sdk version to ^1.1.1 across all relevant package.json files. refactor: streamline bridge message handling - Updated handleBridgeMessage to accept directory parameter for agent and command requests. - Improved local API request handling to extract directory from query parameters and headers. feat: enhance project configuration management - Added functions to retrieve and merge project configuration paths. - Improved handling of existing project configuration files for agents and commands. * feat: enhance VSCode integration and session management - Added support for a sticky sidebar header background in light and dark themes. - Introduced functions to read VSCode workspace directory and check if running in VSCode. - Implemented detailed logging for session loading and creation processes. - Enhanced session filtering based on directory structure and canonical paths. - Added a new method to reorder projects and prevent modifications in VSCode workspace. - Improved error handling and logging for app initialization and markdown file parsing. - Updated API checks and health checks to ensure readiness before proceeding. - Refactored code for better readability and maintainability across various modules. * feat: improve agent and branch selection logic, enhance session management, and update multi-run creation response * feat: add worktree management actions in agent group detail and sidebar, including delete and keep only options * fix(ui): share IME guard and cover multi-run * fix(session): reduce maximum visible sessions in group from 7 to 5
362 lines
12 KiB
TypeScript
362 lines
12 KiB
TypeScript
import { createDesktopAPIs } from './api';
|
|
import { requestInitialNotificationPermission } from './api/notifications';
|
|
import { checkForUpdates, downloadUpdate, restartToUpdate, type UpdateInfo, type UpdateProgress } from './api/updater';
|
|
import { initializeDesktopBridge } from './lib/bridge';
|
|
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { listen } from '@tauri-apps/api/event';
|
|
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
|
|
import type { DesktopApi, DesktopSettings } from '@openchamber/ui/lib/desktop';
|
|
import '@openchamber/ui/index.css';
|
|
import '@openchamber/ui/styles/fonts';
|
|
|
|
if (!(window as typeof globalThis & { process?: unknown }).process) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(window as typeof globalThis & { process?: any }).process = {
|
|
env: {},
|
|
platform: 'darwin',
|
|
version: 'v20.0.0',
|
|
versions: {},
|
|
cwd: () => '/',
|
|
nextTick: (fn: () => void) => Promise.resolve().then(() => fn()),
|
|
};
|
|
}
|
|
|
|
if (import.meta.env.PROD) {
|
|
document.addEventListener('keydown', (e) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'r') {
|
|
e.preventDefault();
|
|
}
|
|
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'r') {
|
|
e.preventDefault();
|
|
}
|
|
});
|
|
|
|
document.addEventListener('contextmenu', (e) => {
|
|
e.preventDefault();
|
|
});
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
|
|
__OPENCHAMBER_HOME__?: string;
|
|
opencodeDesktop?: DesktopApi;
|
|
}
|
|
}
|
|
|
|
const CHECK_FOR_UPDATES_EVENT = 'openchamber:check-for-updates';
|
|
const MENU_ACTION_EVENT = 'openchamber:menu-action';
|
|
|
|
const cleanupFunctions: Array<() => void | Promise<void>> = [];
|
|
|
|
try {
|
|
await initializeDesktopBridge();
|
|
|
|
const activityUnlisten = await listen('openchamber:session-activity', (event) => {
|
|
window.dispatchEvent(new CustomEvent('openchamber:session-activity', { detail: event.payload }));
|
|
});
|
|
cleanupFunctions.push(() => activityUnlisten());
|
|
|
|
const updateCheckUnlisten = await listen(CHECK_FOR_UPDATES_EVENT, () => {
|
|
window.dispatchEvent(new CustomEvent(CHECK_FOR_UPDATES_EVENT));
|
|
});
|
|
cleanupFunctions.push(() => updateCheckUnlisten());
|
|
|
|
const menuActionUnlisten = await listen<string>(MENU_ACTION_EVENT, (event) => {
|
|
window.dispatchEvent(new CustomEvent(MENU_ACTION_EVENT, { detail: event.payload }));
|
|
});
|
|
cleanupFunctions.push(() => menuActionUnlisten());
|
|
|
|
requestInitialNotificationPermission().catch(err => {
|
|
console.error('[main] Failed to request notification permission:', err);
|
|
});
|
|
|
|
window.__OPENCHAMBER_RUNTIME_APIS__ = createDesktopAPIs();
|
|
|
|
cleanupFunctions.push(() => {
|
|
console.info('[main] Cleaning up runtime APIs');
|
|
|
|
if (window.__OPENCHAMBER_RUNTIME_APIS__) {
|
|
/* cleanup placeholder */
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('[main] FATAL: Failed to initialize desktop runtime:', error);
|
|
|
|
for (const cleanup of cleanupFunctions) {
|
|
try {
|
|
const result = cleanup();
|
|
if (result instanceof Promise) {
|
|
await result;
|
|
}
|
|
} catch (cleanupError) {
|
|
console.warn('[main] Cleanup function failed during error handling:', cleanupError);
|
|
}
|
|
}
|
|
|
|
document.body.innerHTML = `
|
|
<div style="padding: 40px; font-family: monospace; color: #ff6b6b; background: #1a1a1a; height: 100vh;">
|
|
<h1>Desktop Runtime Initialization Failed</h1>
|
|
<pre style="background: #2a2a2a; padding: 20px; border-radius: 8px; overflow: auto;">
|
|
${error instanceof Error ? error.stack : String(error)}
|
|
</pre>
|
|
<p style="margin-top: 20px; color: #999;">Press Cmd+Option+I to open DevTools for more details</p>
|
|
</div>
|
|
`;
|
|
throw error;
|
|
}
|
|
|
|
let homeDirectory: string | undefined;
|
|
try {
|
|
const { homeDir } = await import('@tauri-apps/api/path');
|
|
homeDirectory = await homeDir();
|
|
} catch {
|
|
homeDirectory = undefined;
|
|
}
|
|
|
|
if (homeDirectory) {
|
|
window.__OPENCHAMBER_HOME__ = homeDirectory;
|
|
}
|
|
|
|
window.opencodeDesktop = {
|
|
homeDirectory,
|
|
async getServerInfo() {
|
|
try {
|
|
const info = await invoke<ServerInfo>('desktop_server_info');
|
|
return {
|
|
webPort: info.server_port,
|
|
openCodePort: info.opencode_port ?? null,
|
|
host: '127.0.0.1',
|
|
ready: info.opencode_port !== null,
|
|
cliAvailable: info.cli_available ?? false,
|
|
};
|
|
} catch {
|
|
const server = window.__OPENCHAMBER_DESKTOP_SERVER__;
|
|
return {
|
|
webPort: server?.origin ? parseInt(server.origin.split(':')[2] || '0', 10) : null,
|
|
openCodePort: server?.opencodePort ?? null,
|
|
host: '127.0.0.1',
|
|
ready: false,
|
|
cliAvailable: server?.cliAvailable ?? false,
|
|
};
|
|
}
|
|
},
|
|
async getSettings(): Promise<DesktopSettings> {
|
|
const result = await invoke<{ settings: DesktopSettings; source: string }>('load_settings');
|
|
return result.settings;
|
|
},
|
|
async updateSettings(changes: Partial<DesktopSettings>): Promise<DesktopSettings> {
|
|
const result = await invoke<DesktopSettings>('save_settings', { changes });
|
|
return result;
|
|
},
|
|
async restartOpenCode() {
|
|
try {
|
|
await invoke('restart_opencode');
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error('[desktop] Error restarting OpenCode:', error);
|
|
return { success: false };
|
|
}
|
|
},
|
|
async shutdown() {
|
|
return { success: false };
|
|
},
|
|
async getHomeDirectory() {
|
|
return { success: true, path: homeDirectory || null };
|
|
},
|
|
async openExternal(url: string) {
|
|
try {
|
|
await open(url);
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error('[desktop] Error opening external link:', error);
|
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
},
|
|
markRendererReady() {
|
|
|
|
},
|
|
async requestDirectoryAccess(directoryPath?: string) {
|
|
try {
|
|
const normalized = typeof directoryPath === 'string' ? directoryPath.trim() : '';
|
|
|
|
// When the UI already picked a path (typed / directory tree), skip native dialog.
|
|
if (normalized.length > 0) {
|
|
const result = await invoke<{
|
|
success: boolean;
|
|
path?: string;
|
|
projectId?: string;
|
|
error?: string;
|
|
}>('process_directory_selection', {
|
|
path: normalized,
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
const { open } = await import('@tauri-apps/plugin-dialog');
|
|
const selected = await open({
|
|
directory: true,
|
|
multiple: false,
|
|
title: 'Select Working Directory',
|
|
});
|
|
|
|
if (!selected || typeof selected !== 'string') {
|
|
return { success: false, error: 'Directory selection cancelled' };
|
|
}
|
|
|
|
const result = await invoke<{
|
|
success: boolean;
|
|
path?: string;
|
|
projectId?: string;
|
|
error?: string;
|
|
}>('process_directory_selection', {
|
|
path: selected,
|
|
});
|
|
|
|
return result;
|
|
} catch (error) {
|
|
console.error('[desktop] Error requesting directory access:', error);
|
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
},
|
|
async startAccessingDirectory(directoryPath: string) {
|
|
try {
|
|
const result = await invoke<{ success: boolean; error?: string }>('start_accessing_directory', { path: directoryPath });
|
|
return result;
|
|
} catch (error) {
|
|
console.error('[desktop] Error starting directory access:', error);
|
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
},
|
|
|
|
async stopAccessingDirectory(directoryPath: string) {
|
|
try {
|
|
const result = await invoke<{ success: boolean; error?: string }>('stop_accessing_directory', { path: directoryPath });
|
|
return result;
|
|
} catch (error) {
|
|
console.error('[desktop] Error stopping directory access:', error);
|
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
},
|
|
async notifyAssistantCompletion(payload) {
|
|
try {
|
|
const { createDesktopNotificationsAPI } = await import('./api/notifications');
|
|
const result = await createDesktopNotificationsAPI().notifyAgentCompletion(payload);
|
|
return { success: result };
|
|
} catch (error) {
|
|
console.error('[desktop] Error sending notification:', error);
|
|
return { success: false };
|
|
}
|
|
},
|
|
async checkForUpdates(): Promise<UpdateInfo> {
|
|
return checkForUpdates();
|
|
},
|
|
async downloadUpdate(onProgress?: (progress: UpdateProgress) => void): Promise<void> {
|
|
return downloadUpdate(onProgress);
|
|
},
|
|
async restartToUpdate(): Promise<void> {
|
|
return restartToUpdate();
|
|
}
|
|
};
|
|
|
|
console.info('[main] window.opencodeDesktop assigned');
|
|
|
|
if (typeof window !== 'undefined') {
|
|
const handleBeforeUnload = () => {
|
|
console.info('[main] App is unloading, performing cleanup...');
|
|
|
|
cleanupFunctions.forEach((cleanup) => {
|
|
try {
|
|
const result = cleanup();
|
|
if (result instanceof Promise) {
|
|
|
|
result.catch(cleanupError => {
|
|
console.warn('[main] Cleanup function failed during unload:', cleanupError);
|
|
});
|
|
}
|
|
} catch (cleanupError) {
|
|
console.warn('[main] Cleanup function failed during unload:', cleanupError);
|
|
}
|
|
});
|
|
|
|
console.info('[main] Cleanup initiated');
|
|
};
|
|
|
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
|
|
|
window.addEventListener('pagehide', handleBeforeUnload);
|
|
|
|
cleanupFunctions.push(() => {
|
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
|
window.removeEventListener('pagehide', handleBeforeUnload);
|
|
});
|
|
}
|
|
|
|
interface ServerInfo {
|
|
server_port: number;
|
|
opencode_port: number | null;
|
|
api_prefix: string;
|
|
cli_available: boolean;
|
|
has_last_directory: boolean;
|
|
}
|
|
|
|
// Check if we need to prompt for directory selection first
|
|
const promptForDirectoryIfNeeded = async (): Promise<void> => {
|
|
try {
|
|
const info = await invoke<ServerInfo>('desktop_server_info');
|
|
// If CLI available but no saved directory, prompt user
|
|
if (info.cli_available && !info.has_last_directory) {
|
|
console.info('[main] No saved directory - prompting user');
|
|
const { open } = await import('@tauri-apps/plugin-dialog');
|
|
const selected = await open({
|
|
directory: true,
|
|
multiple: false,
|
|
title: 'Select a project folder to get started'
|
|
});
|
|
|
|
if (selected && typeof selected === 'string') {
|
|
await invoke('process_directory_selection', { path: selected });
|
|
await invoke('restart_opencode');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('[main] Directory selection failed:', error);
|
|
}
|
|
};
|
|
|
|
// Check if directory selection is needed, then wait for opencode
|
|
await promptForDirectoryIfNeeded();
|
|
|
|
// Wait for opencode to be ready (or timeout if no CLI)
|
|
const waitForOpencode = async (): Promise<void> => {
|
|
const maxAttempts = 50;
|
|
for (let i = 0; i < maxAttempts; i++) {
|
|
const info = await invoke<ServerInfo>('desktop_server_info');
|
|
// Ready if opencode running, or no CLI (will show onboarding)
|
|
if (!info.cli_available || info.opencode_port !== null) {
|
|
return;
|
|
}
|
|
await new Promise(r => setTimeout(r, 200));
|
|
}
|
|
};
|
|
await waitForOpencode();
|
|
|
|
try {
|
|
await import('@openchamber/ui/main');
|
|
} catch (error) {
|
|
console.error('[main] FATAL: Failed to load UI module:', error);
|
|
document.body.innerHTML = `
|
|
<div style="padding: 40px; font-family: monospace; color: #ff6b6b; background: #1a1a1a; height: 100vh;">
|
|
<h1>UI Module Load Failed</h1>
|
|
<pre style="background: #2a2a2a; padding: 20px; border-radius: 8px; overflow: auto;">
|
|
${error instanceof Error ? error.stack : String(error)}
|
|
</pre>
|
|
<p style="margin-top: 20px; color: #999;">Check DevTools console for details</p>
|
|
</div>
|
|
`;
|
|
throw error;
|
|
}
|