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
+452 -25
View File
@@ -13,6 +13,7 @@ import type {
FilePartInput,
Event,
} from "@opencode-ai/sdk/v2";
import type { PermissionRequest } from "@/types/permission";
type StreamEvent<TData> = {
data: TData;
event?: string;
@@ -20,6 +21,11 @@ type StreamEvent<TData> = {
retry?: number;
};
export type RoutedOpencodeEvent = {
directory: string;
payload: Event;
};
// Use relative path by default (works with both dev and nginx proxy server)
// Can be overridden with VITE_OPENCODE_URL for absolute URLs in special deployments
const DEFAULT_BASE_URL = import.meta.env.VITE_OPENCODE_URL || "/api";
@@ -132,6 +138,14 @@ class OpencodeService {
private sseAbortControllers: Map<string, AbortController> = new Map();
private currentDirectory: string | undefined = undefined;
private globalSseAbortController: AbortController | null = null;
private globalSseTask: Promise<void> | null = null;
private globalSseLastEventId: string | undefined;
private globalSseIsConnected = false;
private globalSseListeners: Set<(event: RoutedOpencodeEvent) => void> = new Set();
private globalSseOpenListeners: Set<() => void> = new Set();
private globalSseErrorListeners: Set<(error: unknown) => void> = new Set();
constructor(baseUrl: string = DEFAULT_BASE_URL) {
const desktopBase = resolveDesktopBaseUrl();
const requestedBaseUrl = desktopBase || baseUrl;
@@ -726,21 +740,46 @@ class OpencodeService {
return this.getSessionStatusForDirectory(null);
}
// Tools
async listToolIds(options?: { directory?: string | null }): Promise<string[]> {
try {
const directory = typeof options?.directory === 'string'
? options.directory.trim()
: (this.currentDirectory ? this.currentDirectory.trim() : '');
const result = await this.client.tool.ids(directory ? { directory } : undefined);
const tools = (result.data || []) as unknown as string[];
return tools.filter((tool) => typeof tool === 'string' && tool !== 'invalid');
} catch {
return [];
}
}
// Permissions
async respondToPermission(
sessionId: string,
permissionId: string,
response: 'once' | 'always' | 'reject'
async replyToPermission(
requestId: string,
reply: 'once' | 'always' | 'reject',
options?: { message?: string }
): Promise<boolean> {
const result = await this.client.permission.respond({
sessionID: sessionId,
permissionID: permissionId,
const result = await this.client.permission.reply({
requestID: requestId,
...(this.currentDirectory ? { directory: this.currentDirectory } : {}),
response
reply,
...(options?.message ? { message: options.message } : {}),
});
return result.data || false;
}
async listPendingPermissions(): Promise<PermissionRequest[]> {
try {
// Permission requests are global across sessions; do not scope by directory.
const result = await this.client.permission.list();
return (result.data || []) as unknown as PermissionRequest[];
} catch {
return [];
}
}
// Configuration
async getConfig(): Promise<Config> {
const response = await this.client.config.get();
@@ -773,7 +812,7 @@ class OpencodeService {
/**
* Update config with a partial modification function.
* This handles the GET-modify-PATCH pattern required by OpenCode API.
* This handles the GET-modify-PATCH pattern required by the upstream API.
*
* NOTE: This method is deprecated for agent configuration.
* Use backend endpoints at /api/config/agents/* instead, which write directly to files.
@@ -830,6 +869,286 @@ class OpencodeService {
}
}
private parseSseBlock(block: string): { data: unknown; id?: string } | null {
if (!block) return null;
const lines = block.split('\n');
const dataLines: string[] = [];
let eventId: string | undefined;
for (const line of lines) {
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).replace(/^\s/, ''));
} else if (line.startsWith('id:')) {
const candidate = line.slice(3).trim();
if (candidate) {
eventId = candidate;
}
}
}
if (dataLines.length === 0) {
return null;
}
const payloadText = dataLines.join('\n').trim();
if (!payloadText) {
return null;
}
try {
const data = JSON.parse(payloadText) as unknown;
return { data, id: eventId };
} catch {
return null;
}
}
private normalizeRoutedSsePayload(raw: unknown): RoutedOpencodeEvent | null {
if (!raw || typeof raw !== 'object') {
return null;
}
const record = raw as Record<string, unknown>;
const directoryCandidate =
typeof record.directory === 'string'
? record.directory
: typeof record.properties === 'object' && record.properties !== null
? ((record.properties as Record<string, unknown>).directory as unknown)
: null;
const normalizedDirectory =
typeof directoryCandidate === 'string'
? this.normalizeCandidatePath(directoryCandidate) ?? directoryCandidate.trim()
: null;
if (typeof record.type === 'string') {
return {
directory: normalizedDirectory && normalizedDirectory.length > 0 ? normalizedDirectory : 'global',
payload: record as Event,
};
}
const nestedPayload = record.payload;
if (nestedPayload && typeof nestedPayload === 'object') {
const nestedRecord = nestedPayload as Record<string, unknown>;
if (typeof nestedRecord.type === 'string') {
return {
directory: normalizedDirectory && normalizedDirectory.length > 0 ? normalizedDirectory : 'global',
payload: nestedRecord as Event,
};
}
}
return null;
}
private emitGlobalSseEvent(event: RoutedOpencodeEvent) {
for (const listener of this.globalSseListeners) {
try {
listener(event);
} catch (error) {
console.warn('[OpencodeClient] Global SSE listener error:', error);
}
}
}
private notifyGlobalSseOpen() {
for (const handler of this.globalSseOpenListeners) {
try {
handler();
} catch (error) {
console.warn('[OpencodeClient] Global SSE open handler error:', error);
}
}
}
private notifyGlobalSseError(error: unknown) {
for (const handler of this.globalSseErrorListeners) {
try {
handler(error);
} catch (listenerError) {
console.warn('[OpencodeClient] Global SSE error handler failed:', listenerError);
}
}
}
private ensureGlobalSseStarted() {
if (this.globalSseTask) {
return;
}
const abortController = new AbortController();
this.globalSseAbortController = abortController;
this.globalSseTask = this.runGlobalSseLoop(abortController)
.catch((error) => {
if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) {
return;
}
console.error('[OpencodeClient] Global SSE task failed:', error);
})
.finally(() => {
if (this.globalSseAbortController === abortController) {
this.globalSseAbortController = null;
}
this.globalSseTask = null;
this.globalSseIsConnected = false;
});
}
private maybeStopGlobalSse() {
if (this.globalSseListeners.size > 0) {
return;
}
if (this.globalSseAbortController && !this.globalSseAbortController.signal.aborted) {
this.globalSseAbortController.abort();
}
this.globalSseAbortController = null;
}
private async runGlobalSseLoop(abortController: AbortController): Promise<void> {
const globalEndpoint = `${this.baseUrl.replace(/\/+$/, '')}/global/event`;
let attempt = 0;
while (!abortController.signal.aborted) {
try {
const headers: Record<string, string> = {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
};
if (this.globalSseLastEventId) {
headers['Last-Event-ID'] = this.globalSseLastEventId;
}
const response = await fetch(globalEndpoint, {
method: 'GET',
headers,
signal: abortController.signal,
});
if (!response.ok || !response.body) {
throw new Error(`Global SSE connect failed with status ${response.status}`);
}
attempt = 0;
this.globalSseIsConnected = true;
if (!abortController.signal.aborted) {
this.notifyGlobalSseOpen();
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (abortController.signal.aborted) break;
if (!value || value.length === 0) continue;
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
const blocks = buffer.split('\n\n');
buffer = blocks.pop() ?? '';
for (const block of blocks) {
const parsed = this.parseSseBlock(block);
if (!parsed) continue;
if (parsed.id) {
this.globalSseLastEventId = parsed.id;
}
const routed = this.normalizeRoutedSsePayload(parsed.data);
if (routed) {
this.emitGlobalSseEvent(routed);
}
}
}
const remaining = buffer.trim();
if (remaining && !abortController.signal.aborted) {
const parsed = this.parseSseBlock(remaining);
if (parsed?.id) {
this.globalSseLastEventId = parsed.id;
}
const routed = parsed ? this.normalizeRoutedSsePayload(parsed.data) : null;
if (routed) {
this.emitGlobalSseEvent(routed);
}
}
// Stream ended; force reconnect.
this.globalSseIsConnected = false;
} catch (error: unknown) {
this.globalSseIsConnected = false;
if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) {
return;
}
console.error('[OpencodeClient] Global SSE stream error (will retry):', error);
this.notifyGlobalSseError(error);
}
if (abortController.signal.aborted) {
break;
}
attempt += 1;
const delay = Math.min(3000 * Math.pow(2, attempt), 30000);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
subscribeToGlobalEvents(
onEvent: (event: RoutedOpencodeEvent) => void,
onError?: (error: unknown) => void,
onOpen?: () => void,
options?: { directory?: string | null }
): () => void {
const directoryFilter = this.normalizeCandidatePath(options?.directory ?? null);
const listener = (event: RoutedOpencodeEvent) => {
if (directoryFilter && event.directory !== directoryFilter) {
return;
}
onEvent(event);
};
this.globalSseListeners.add(listener);
if (onOpen) {
this.globalSseOpenListeners.add(onOpen);
if (this.globalSseIsConnected) {
setTimeout(() => {
if (this.globalSseOpenListeners.has(onOpen)) {
try {
onOpen();
} catch (error) {
console.warn('[OpencodeClient] Global SSE open handler error:', error);
}
}
}, 0);
}
}
if (onError) {
this.globalSseErrorListeners.add(onError);
}
this.ensureGlobalSseStarted();
return () => {
this.globalSseListeners.delete(listener);
if (onOpen) {
this.globalSseOpenListeners.delete(onOpen);
}
if (onError) {
this.globalSseErrorListeners.delete(onError);
}
this.maybeStopGlobalSse();
};
}
// Event Streaming using SDK SSE (Server-Sent Events) with AsyncGenerator
subscribeToEvents(
onMessage: (event: { type: string; properties?: Record<string, unknown> }) => void,
@@ -839,6 +1158,7 @@ class OpencodeService {
options?: { scope?: 'global' | 'directory'; key?: string }
): () => void {
const subscriptionKey = options?.key ?? 'default';
const scope = options?.scope ?? 'directory';
const existingController = this.sseAbortControllers.get(subscriptionKey);
if (existingController) {
existingController.abort();
@@ -850,17 +1170,122 @@ class OpencodeService {
let lastEventId: string | undefined;
if (scope === 'global') {
let globalUnsub: (() => void) | null = null;
const attachDirectory = (event: RoutedOpencodeEvent): Event => {
if (event.directory === 'global') {
return event.payload;
}
const payloadRecord = event.payload as unknown as Record<string, unknown>;
const existingProperties =
typeof payloadRecord.properties === 'object' && payloadRecord.properties !== null
? (payloadRecord.properties as Record<string, unknown>)
: {};
if (existingProperties.directory === event.directory) {
return event.payload;
}
return {
...payloadRecord,
properties: {
...existingProperties,
directory: event.directory,
},
} as Event;
};
const cleanup = () => {
if (globalUnsub) {
try {
globalUnsub();
} catch {
// ignore
}
globalUnsub = null;
}
if (this.sseAbortControllers.get(subscriptionKey) === abortController) {
this.sseAbortControllers.delete(subscriptionKey);
}
};
abortController.signal.addEventListener('abort', cleanup, { once: true });
globalUnsub = this.subscribeToGlobalEvents(
(event) => {
if (abortController.signal.aborted) {
return;
}
onMessage(attachDirectory(event));
},
onError
? (error) => {
if (!abortController.signal.aborted) {
onError(error);
}
}
: undefined,
onOpen
? () => {
if (!abortController.signal.aborted) {
onOpen();
}
}
: undefined,
);
return () => {
cleanup();
abortController.abort();
};
}
const normalizeEventPayload = (payload: unknown): Event | null => {
if (!payload || typeof payload !== 'object') {
return null;
}
const record = payload as Record<string, unknown>;
if (typeof record.type === 'string') {
return record as Event;
}
const nestedPayload = record.payload;
if (nestedPayload && typeof nestedPayload === 'object') {
const nestedRecord = nestedPayload as Record<string, unknown>;
if (typeof nestedRecord.type === 'string') {
if (typeof record.directory === 'string' && record.directory.length > 0) {
const existingProperties =
typeof nestedRecord.properties === 'object' && nestedRecord.properties !== null
? (nestedRecord.properties as Record<string, unknown>)
: null;
const properties = {
...(existingProperties ?? {}),
directory: record.directory,
};
return { ...nestedRecord, properties } as Event;
}
return nestedRecord as Event;
}
}
return null;
};
console.log('[OpencodeClient] Starting SSE subscription...');
// Start async generator in background with reconnect on failure
(async () => {
const resolvedDirectory =
options?.scope === 'global'
? undefined
: typeof directoryOverride === 'string' && directoryOverride.trim().length > 0
? directoryOverride.trim()
: this.currentDirectory;
console.log('[OpencodeClient] Connecting to SSE with directory:', resolvedDirectory ?? 'global');
typeof directoryOverride === 'string' && directoryOverride.trim().length > 0
? directoryOverride.trim()
: this.currentDirectory;
console.log('[OpencodeClient] Connecting to SSE with directory:', resolvedDirectory ?? 'default');
const connect = async (attempt: number): Promise<void> => {
try {
@@ -891,8 +1316,9 @@ class OpencodeService {
lastEventId = event.id;
}
const payload = event.data;
if (payload && typeof payload === 'object') {
onMessage(payload as Event);
const normalized = normalizeEventPayload(payload);
if (normalized) {
onMessage(normalized);
}
},
};
@@ -915,13 +1341,6 @@ class OpencodeService {
break;
}
}
if (!abortController.signal.aborted) {
// Stream ended unexpectedly; attempt reconnect
const delay = Math.min(3000 * Math.pow(2, attempt), 30000);
await new Promise((resolve) => setTimeout(resolve, delay));
await connect(attempt + 1);
}
} catch (error: unknown) {
if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) {
console.log('[OpencodeClient] SSE stream aborted normally');
@@ -936,6 +1355,13 @@ class OpencodeService {
if (!abortController.signal.aborted) {
await connect(attempt + 1);
}
return;
}
if (!abortController.signal.aborted) {
const delay = Math.min(3000 * Math.pow(2, attempt), 30000);
await new Promise((resolve) => setTimeout(resolve, delay));
await connect(attempt + 1);
}
};
@@ -956,6 +1382,7 @@ class OpencodeService {
}
abortController.abort();
};
}
// File Operations
@@ -1092,7 +1519,7 @@ class OpencodeService {
const healthData = await response.json();
// Check if OpenCode is actually ready (not just OpenChamber server)
// Check if the upstream API is ready (not just OpenChamber server)
if (healthData.isOpenCodeReady === false) {
return false;
}