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
File diff suppressed because it is too large Load Diff
@@ -18,7 +18,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { RiAddLine, RiAiAgentFill, RiAiAgentLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiRobot2Line, RiRobotLine, RiRestartLine, RiEditLine } from '@remixicon/react';
import { useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope } from '@/stores/useAgentsStore';
import { useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope, type AgentDraft } from '@/stores/useAgentsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime } from '@/lib/desktop';
@@ -30,6 +30,103 @@ interface AgentsSidebarProps {
onItemSelect?: () => void;
}
type PermissionAction = 'allow' | 'ask' | 'deny';
type PermissionRule = { permission: string; pattern: string; action: PermissionAction };
type PermissionConfigValue = PermissionAction | Record<string, PermissionAction>;
// OpenCode's built-in defaults for permissions that differ from "allow"
const getOpenCodeDefaultActionForPermission = (permissionName: string): PermissionAction => {
if (permissionName === 'doom_loop' || permissionName === 'external_directory') {
return 'ask';
}
return 'allow';
};
const toPermissionRuleset = (ruleset: unknown): PermissionRule[] => {
if (!Array.isArray(ruleset)) {
return [];
}
const parsed: PermissionRule[] = [];
for (const entry of ruleset) {
if (!entry || typeof entry !== 'object') {
continue;
}
const candidate = entry as Partial<PermissionRule>;
if (typeof candidate.permission !== 'string' || typeof candidate.pattern !== 'string' || typeof candidate.action !== 'string') {
continue;
}
if (candidate.action !== 'allow' && candidate.action !== 'ask' && candidate.action !== 'deny') {
continue;
}
parsed.push({ permission: candidate.permission, pattern: candidate.pattern, action: candidate.action });
}
return parsed;
};
const rulesetToPermissionConfig = (ruleset: unknown): AgentDraft['permission'] => {
const parsed = toPermissionRuleset(ruleset);
if (parsed.length === 0) {
return undefined;
}
const byPermission: Record<string, Record<string, PermissionAction>> = {};
for (const rule of parsed) {
if (!rule.permission) {
continue;
}
(byPermission[rule.permission] ||= {})[rule.pattern] = rule.action;
}
// Get the global default (wildcard * with pattern *)
const globalDefault = byPermission['*']?.['*'];
const permissionNames = Object.keys(byPermission);
if (
permissionNames.length === 1 &&
permissionNames[0] === '*' &&
Object.keys(byPermission['*'] || {}).length === 1 &&
byPermission['*']?.['*']
) {
return byPermission['*']['*'];
}
const result: Record<string, PermissionConfigValue> = {};
for (const permissionName of permissionNames) {
const map = byPermission[permissionName];
const patterns = Object.keys(map);
// For wildcard-only entries, check if they're redundant
if (patterns.length === 1 && patterns[0] === '*' && permissionName !== '*') {
const action = map['*'];
const opencodeDefault = getOpenCodeDefaultActionForPermission(permissionName);
// Skip if this permission is redundant (matches effective default)
if (globalDefault) {
if (action === globalDefault) continue;
} else {
if (action === opencodeDefault) continue;
}
result[permissionName] = action;
} else if (permissionName === '*') {
// Include global default
if (patterns.length === 1 && patterns[0] === '*') {
result[permissionName] = map['*'];
} else {
result[permissionName] = map;
}
} else {
// Non-wildcard patterns - include as-is
result[permissionName] = map;
}
}
return Object.keys(result).length > 0 ? (result as AgentDraft['permission']) : undefined;
};
export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) => {
const [renameDialogAgent, setRenameDialogAgent] = React.useState<Agent | null>(null);
const [renameNewName, setRenameNewName] = React.useState('');
@@ -132,12 +229,9 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
// Set draft with prefilled values from source agent
const extAgent = agent as Agent & { scope?: AgentScope };
// Convert model object to string if needed (SDK type vs API type difference)
const modelStr = typeof agent.model === 'string'
? agent.model
: agent.model?.providerID && agent.model?.modelID
? `${agent.model.providerID}/${agent.model.modelID}`
: undefined;
const modelStr = agent.model?.providerID && agent.model?.modelID
? `${agent.model.providerID}/${agent.model.modelID}`
: null;
const draftAgent = agent as Agent & { disable?: boolean };
setAgentDraft({
name: newName,
@@ -148,8 +242,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
top_p: agent.topP,
prompt: agent.prompt,
mode: agent.mode,
tools: agent.tools,
permission: agent.permission,
permission: rulesetToPermissionConfig(agent.permission),
disable: draftAgent.disable,
});
setSelectedAgent(newName);
@@ -185,12 +278,9 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
}
// Create new agent with new name and all existing config
// Convert model object to string if needed (SDK type vs API type difference)
const renameModelStr = typeof renameDialogAgent.model === 'string'
? renameDialogAgent.model
: renameDialogAgent.model?.providerID && renameDialogAgent.model?.modelID
? `${renameDialogAgent.model.providerID}/${renameDialogAgent.model.modelID}`
: undefined;
const renameModelStr = renameDialogAgent.model?.providerID && renameDialogAgent.model?.modelID
? `${renameDialogAgent.model.providerID}/${renameDialogAgent.model.modelID}`
: null;
const renameExt = renameDialogAgent as Agent & { scope?: AgentScope; disable?: boolean };
const success = await createAgent({
name: sanitizedName,
@@ -200,8 +290,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
top_p: renameDialogAgent.topP,
prompt: renameDialogAgent.prompt,
mode: renameDialogAgent.mode,
tools: renameDialogAgent.tools,
permission: renameDialogAgent.permission,
permission: rulesetToPermissionConfig(renameDialogAgent.permission),
disable: renameExt.disable,
scope: renameExt.scope,
});