Files
openchamber/packages/desktop/src/main.tsx
T
Bohdan Triapitsyn 18c5b4c7b5 feat: add multi-project support (#110)
* 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
2026-01-06 21:31:04 +02:00

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;
}