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
This commit is contained in:
Bohdan Triapitsyn
2026-01-06 21:31:04 +02:00
committed by GitHub
parent 8aa379e313
commit 18c5b4c7b5
84 changed files with 8399 additions and 2854 deletions
+447
View File
@@ -0,0 +1,447 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { opencodeClient } from '@/lib/opencode/client';
import type { ProjectEntry } from '@/lib/api/types';
import type { DesktopSettings } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence';
import { getSafeStorage } from './utils/safeStorage';
import { useDirectoryStore } from './useDirectoryStore';
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
interface ProjectPathValidationResult {
ok: boolean;
normalizedPath?: string;
reason?: string;
}
interface ProjectsStore {
projects: ProjectEntry[];
activeProjectId: string | null;
addProject: (path: string, options?: { label?: string; id?: string }) => ProjectEntry | null;
removeProject: (id: string) => void;
setActiveProject: (id: string) => void;
setActiveProjectIdOnly: (id: string) => void;
renameProject: (id: string, label: string) => void;
reorderProjects: (fromIndex: number, toIndex: number) => void;
validateProjectPath: (path: string) => ProjectPathValidationResult;
synchronizeFromSettings: (settings: DesktopSettings) => void;
getActiveProject: () => ProjectEntry | null;
}
const safeStorage = getSafeStorage();
const PROJECTS_STORAGE_KEY = 'projects';
const ACTIVE_PROJECT_STORAGE_KEY = 'activeProjectId';
const resolveTildePath = (value: string, homeDir?: string | null): string => {
const trimmed = value.trim();
if (!trimmed.startsWith('~')) {
return trimmed;
}
if (!homeDir) {
return trimmed;
}
if (trimmed === '~') {
return homeDir;
}
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
return `${homeDir}${trimmed.slice(1)}`;
}
return trimmed;
};
const normalizeProjectPath = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) {
return '';
}
const homeDirectory = safeStorage.getItem('homeDirectory') || useDirectoryStore.getState().homeDirectory || '';
const expanded = resolveTildePath(trimmed, homeDirectory);
const normalized = expanded.replace(/\\/g, '/');
if (normalized === '/') {
return '/';
}
return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized;
};
const deriveProjectLabel = (path: string): string => {
const normalized = normalizeProjectPath(path);
if (!normalized || normalized === '/') {
return 'Root';
}
const segments = normalized.split('/').filter(Boolean);
return segments[segments.length - 1] || normalized;
};
const createProjectId = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `proj_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
};
const sanitizeProjects = (value: unknown): ProjectEntry[] => {
if (!Array.isArray(value)) {
return [];
}
const result: ProjectEntry[] = [];
const seenIds = new Set<string>();
const seenPaths = new Set<string>();
for (const entry of value) {
if (!entry || typeof entry !== 'object') continue;
const candidate = entry as Record<string, unknown>;
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : '';
if (!id || !rawPath) continue;
const normalizedPath = normalizeProjectPath(rawPath);
if (!normalizedPath) continue;
if (seenIds.has(id) || seenPaths.has(normalizedPath)) continue;
seenIds.add(id);
seenPaths.add(normalizedPath);
const project: ProjectEntry = {
id,
path: normalizedPath,
};
if (typeof candidate.label === 'string' && candidate.label.trim().length > 0) {
project.label = candidate.label.trim();
}
if (typeof candidate.addedAt === 'number' && Number.isFinite(candidate.addedAt) && candidate.addedAt >= 0) {
project.addedAt = candidate.addedAt;
}
if (typeof candidate.lastOpenedAt === 'number' && Number.isFinite(candidate.lastOpenedAt) && candidate.lastOpenedAt >= 0) {
project.lastOpenedAt = candidate.lastOpenedAt;
}
result.push(project);
}
return result;
};
const readPersistedProjects = (): ProjectEntry[] => {
try {
const raw = safeStorage.getItem(PROJECTS_STORAGE_KEY);
if (!raw) {
return [];
}
return sanitizeProjects(JSON.parse(raw));
} catch {
return [];
}
};
const readPersistedActiveProjectId = (): string | null => {
try {
const raw = safeStorage.getItem(ACTIVE_PROJECT_STORAGE_KEY);
if (typeof raw === 'string' && raw.trim().length > 0) {
return raw.trim();
}
} catch {
return null;
}
return null;
};
const cacheProjects = (projects: ProjectEntry[], activeProjectId: string | null) => {
try {
safeStorage.setItem(PROJECTS_STORAGE_KEY, JSON.stringify(projects));
} catch {
// ignored
}
try {
if (activeProjectId) {
safeStorage.setItem(ACTIVE_PROJECT_STORAGE_KEY, activeProjectId);
} else {
safeStorage.removeItem(ACTIVE_PROJECT_STORAGE_KEY);
}
} catch {
// ignored
}
};
const persistProjects = (projects: ProjectEntry[], activeProjectId: string | null) => {
cacheProjects(projects, activeProjectId);
void updateDesktopSettings({ projects, activeProjectId: activeProjectId ?? undefined });
};
const initialProjects = readPersistedProjects();
const getVSCodeWorkspaceProject = (): { projects: ProjectEntry[]; activeProjectId: string | null } | null => {
if (typeof window === 'undefined') {
return null;
}
const runtimeApis = (window as unknown as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isVSCode?: boolean } } })
.__OPENCHAMBER_RUNTIME_APIS__;
if (!runtimeApis?.runtime?.isVSCode) {
return null;
}
const workspaceFolder = (window as unknown as { __VSCODE_CONFIG__?: { workspaceFolder?: unknown } }).__VSCODE_CONFIG__?.workspaceFolder;
if (typeof workspaceFolder !== 'string' || workspaceFolder.trim().length === 0) {
return null;
}
const normalizedPath = normalizeProjectPath(workspaceFolder);
if (!normalizedPath) {
return null;
}
const id = `vscode:${normalizedPath}`;
const entry: ProjectEntry = {
id,
path: normalizedPath,
label: deriveProjectLabel(normalizedPath),
addedAt: Date.now(),
lastOpenedAt: Date.now(),
};
if (streamDebugEnabled()) {
console.log('[OpenChamber][VSCode][projects] Using workspace fallback project', entry);
}
return { projects: [entry], activeProjectId: id };
};
// VS Code runtime should behave as a single-project environment scoped to the workspace folder.
// Always prefer the workspace project over any persisted multi-project registry.
const vscodeWorkspace = getVSCodeWorkspaceProject();
const effectiveInitialProjects = vscodeWorkspace?.projects ?? initialProjects;
const initialActiveProjectId = vscodeWorkspace?.activeProjectId
?? readPersistedActiveProjectId()
?? effectiveInitialProjects[0]?.id
?? null;
if (vscodeWorkspace) {
cacheProjects(effectiveInitialProjects, initialActiveProjectId);
}
export const useProjectsStore = create<ProjectsStore>()(
devtools((set, get) => ({
projects: effectiveInitialProjects,
activeProjectId: initialActiveProjectId,
validateProjectPath: (path: string): ProjectPathValidationResult => {
if (typeof path !== 'string' || path.trim().length === 0) {
return { ok: false, reason: 'Provide a directory path.' };
}
const normalized = normalizeProjectPath(path);
if (!normalized) {
return { ok: false, reason: 'Directory path cannot be empty.' };
}
return { ok: true, normalizedPath: normalized };
},
addProject: (path: string, options?: { label?: string; id?: string }) => {
if (vscodeWorkspace) {
return null;
}
const { validateProjectPath } = get();
const validation = validateProjectPath(path);
if (!validation.ok || !validation.normalizedPath) {
return null;
}
const normalizedPath = validation.normalizedPath;
const existing = get().projects.find((project) => project.path === normalizedPath);
if (existing) {
get().setActiveProject(existing.id);
return existing;
}
const now = Date.now();
const label = options?.label?.trim() || deriveProjectLabel(normalizedPath);
const candidateId = options?.id?.trim();
const id = candidateId && !get().projects.some((project) => project.id === candidateId)
? candidateId
: createProjectId();
const entry: ProjectEntry = {
id,
path: normalizedPath,
label,
addedAt: now,
lastOpenedAt: now,
};
const nextProjects = [...get().projects, entry];
set({ projects: nextProjects });
if (streamDebugEnabled()) {
console.info('[ProjectsStore] Added project', entry);
}
get().setActiveProject(entry.id);
return entry;
},
removeProject: (id: string) => {
if (vscodeWorkspace) {
return;
}
const current = get();
const nextProjects = current.projects.filter((project) => project.id !== id);
let nextActiveId = current.activeProjectId;
if (current.activeProjectId === id) {
nextActiveId = nextProjects[0]?.id ?? null;
}
set({ projects: nextProjects, activeProjectId: nextActiveId });
persistProjects(nextProjects, nextActiveId);
if (nextActiveId) {
const nextActive = nextProjects.find((project) => project.id === nextActiveId);
if (nextActive) {
opencodeClient.setDirectory(nextActive.path);
useDirectoryStore.getState().setDirectory(nextActive.path, { showOverlay: false });
}
} else {
void useDirectoryStore.getState().goHome();
}
},
setActiveProject: (id: string) => {
if (vscodeWorkspace) {
return;
}
const { projects, activeProjectId } = get();
if (activeProjectId === id) {
return;
}
const target = projects.find((project) => project.id === id);
if (!target) {
return;
}
const now = Date.now();
const nextProjects = projects.map((project) =>
project.id === id ? { ...project, lastOpenedAt: now } : project
);
set({ projects: nextProjects, activeProjectId: id });
persistProjects(nextProjects, id);
opencodeClient.setDirectory(target.path);
useDirectoryStore.getState().setDirectory(target.path, { showOverlay: false });
},
setActiveProjectIdOnly: (id: string) => {
if (vscodeWorkspace) {
return;
}
const { projects, activeProjectId } = get();
if (activeProjectId === id) {
return;
}
const target = projects.find((project) => project.id === id);
if (!target) {
return;
}
const now = Date.now();
const nextProjects = projects.map((project) =>
project.id === id ? { ...project, lastOpenedAt: now } : project
);
set({ projects: nextProjects, activeProjectId: id });
persistProjects(nextProjects, id);
},
renameProject: (id: string, label: string) => {
if (vscodeWorkspace) {
return;
}
const trimmed = label.trim();
if (!trimmed) {
return;
}
const { projects, activeProjectId } = get();
const nextProjects = projects.map((project) =>
project.id === id ? { ...project, label: trimmed } : project
);
set({ projects: nextProjects });
persistProjects(nextProjects, activeProjectId);
},
reorderProjects: (fromIndex: number, toIndex: number) => {
if (vscodeWorkspace) {
return;
}
const { projects, activeProjectId } = get();
if (
fromIndex < 0 ||
fromIndex >= projects.length ||
toIndex < 0 ||
toIndex >= projects.length ||
fromIndex === toIndex
) {
return;
}
const nextProjects = [...projects];
const [moved] = nextProjects.splice(fromIndex, 1);
nextProjects.splice(toIndex, 0, moved);
set({ projects: nextProjects });
persistProjects(nextProjects, activeProjectId);
},
synchronizeFromSettings: (settings: DesktopSettings) => {
if (vscodeWorkspace) {
return;
}
const incomingProjects = sanitizeProjects(settings.projects ?? []);
const incomingActive = typeof settings.activeProjectId === 'string' && settings.activeProjectId.trim()
? settings.activeProjectId.trim()
: null;
const current = get();
const projectsChanged = JSON.stringify(current.projects) !== JSON.stringify(incomingProjects);
const activeChanged = current.activeProjectId !== incomingActive;
if (!projectsChanged && !activeChanged) {
return;
}
set({ projects: incomingProjects, activeProjectId: incomingActive });
cacheProjects(incomingProjects, incomingActive);
if (incomingActive) {
const activeProject = incomingProjects.find((project) => project.id === incomingActive);
if (activeProject) {
opencodeClient.setDirectory(activeProject.path);
useDirectoryStore.getState().setDirectory(activeProject.path, { showOverlay: false });
}
}
},
getActiveProject: () => {
const { projects, activeProjectId } = get();
if (!activeProjectId) {
return null;
}
return projects.find((project) => project.id === activeProjectId) ?? null;
},
}), { name: 'projects-store' })
);
if (typeof window !== 'undefined') {
window.addEventListener('openchamber:settings-synced', (event: Event) => {
const detail = (event as CustomEvent<DesktopSettings>).detail;
if (detail && typeof detail === 'object') {
useProjectsStore.getState().synchronizeFromSettings(detail);
}
});
}