Files
openchamber/packages/ui/src/stores/useSkillsStore.ts
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

396 lines
13 KiB
TypeScript

import { create } from "zustand";
import type { StoreApi, UseBoundStore } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware";
import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync";
import {
startConfigUpdate,
finishConfigUpdate,
} from "@/lib/configUpdate";
import { getSafeStorage } from "./utils/safeStorage";
import { opencodeClient } from '@/lib/opencode/client';
const getCurrentDirectory = (): string | null => {
const opencodeDirectory = opencodeClient.getDirectory();
if (typeof opencodeDirectory === 'string' && opencodeDirectory.trim().length > 0) {
return opencodeDirectory;
}
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const store = (window as any).__zustand_directory_store__;
if (store) {
return store.getState().currentDirectory;
}
} catch {
// ignore
}
return null;
};
export type SkillScope = 'user' | 'project';
export type SkillSource = 'opencode' | 'claude';
export interface SupportingFile {
name: string;
path: string;
fullPath: string;
}
export interface SkillSources {
md: {
exists: boolean;
path: string | null;
dir: string | null;
fields: string[];
scope?: SkillScope | null;
source?: SkillSource | null;
supportingFiles: SupportingFile[];
// Actual content values
name?: string;
description?: string;
instructions?: string;
};
projectMd?: { exists: boolean; path: string | null };
claudeMd?: { exists: boolean; path: string | null };
userMd?: { exists: boolean; path: string | null };
}
export interface DiscoveredSkill {
name: string;
path: string;
scope: SkillScope;
source: SkillSource;
}
export interface SkillConfig {
name: string;
description: string;
instructions?: string;
scope?: SkillScope;
supportingFiles?: Array<{ path: string; content: string }>;
}
export interface PendingFile {
path: string;
content: string;
}
export interface SkillDraft {
name: string;
scope: SkillScope;
description: string;
instructions?: string;
pendingFiles?: PendingFile[];
}
export interface SkillDetail {
name: string;
sources: SkillSources;
scope?: SkillScope | null;
source?: SkillSource | null;
}
interface SkillsStore {
selectedSkillName: string | null;
skills: DiscoveredSkill[];
isLoading: boolean;
skillDraft: SkillDraft | null;
setSelectedSkill: (name: string | null) => void;
setSkillDraft: (draft: SkillDraft | null) => void;
loadSkills: () => Promise<boolean>;
getSkillDetail: (name: string) => Promise<SkillDetail | null>;
createSkill: (config: SkillConfig) => Promise<boolean>;
updateSkill: (name: string, config: Partial<SkillConfig>) => Promise<boolean>;
deleteSkill: (name: string) => Promise<boolean>;
getSkillByName: (name: string) => DiscoveredSkill | undefined;
// Supporting files
readSupportingFile: (skillName: string, filePath: string) => Promise<string | null>;
writeSupportingFile: (skillName: string, filePath: string, content: string) => Promise<boolean>;
deleteSupportingFile: (skillName: string, filePath: string) => Promise<boolean>;
}
declare global {
interface Window {
__zustand_skills_store__?: UseBoundStore<StoreApi<SkillsStore>>;
}
}
const CONFIG_EVENT_SOURCE = "useSkillsStore";
export const useSkillsStore = create<SkillsStore>()(
devtools(
persist(
(set, get) => ({
selectedSkillName: null,
skills: [],
isLoading: false,
skillDraft: null,
setSelectedSkill: (name: string | null) => {
set({ selectedSkillName: name });
},
setSkillDraft: (draft: SkillDraft | null) => {
set({ skillDraft: draft });
},
loadSkills: async () => {
set({ isLoading: true });
const previousSkills = get().skills;
let lastError: unknown = null;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await fetch(`/api/config/skills${queryParams}`);
if (!response.ok) {
throw new Error(`Failed to list skills: ${response.status}`);
}
const data = await response.json();
const skills = (data.skills || []) as DiscoveredSkill[];
set({ skills, isLoading: false });
return true;
} catch (error) {
lastError = error;
const waitMs = 200 * (attempt + 1);
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
}
console.error("Failed to load skills:", lastError);
set({ skills: previousSkills, isLoading: false });
return false;
},
getSkillDetail: async (name: string) => {
try {
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await fetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`);
if (!response.ok) {
return null;
}
return await response.json() as SkillDetail;
} catch {
return null;
}
},
createSkill: async (config: SkillConfig) => {
startConfigUpdate("Creating skill...");
try {
const skillConfig: Record<string, unknown> = {
name: config.name,
description: config.description,
};
if (config.instructions) skillConfig.instructions = config.instructions;
if (config.scope) skillConfig.scope = config.scope;
if (config.supportingFiles) skillConfig.supportingFiles = config.supportingFiles;
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await fetch(`/api/config/skills/${encodeURIComponent(config.name)}${queryParams}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(skillConfig)
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || 'Failed to create skill';
throw new Error(message);
}
// Skills are just files - no need to reload OpenCode
// Just refresh our local list
const loaded = await get().loadSkills();
if (loaded) {
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
}
return loaded;
} catch {
return false;
} finally {
finishConfigUpdate();
}
},
updateSkill: async (name: string, config: Partial<SkillConfig>) => {
startConfigUpdate("Updating skill...");
try {
const skillConfig: Record<string, unknown> = {};
if (config.description !== undefined) skillConfig.description = config.description;
if (config.instructions !== undefined) skillConfig.instructions = config.instructions;
if (config.supportingFiles !== undefined) skillConfig.supportingFiles = config.supportingFiles;
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await fetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(skillConfig)
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || 'Failed to update skill';
throw new Error(message);
}
// Skills are just files - no need to reload OpenCode
// Just refresh our local list
const loaded = await get().loadSkills();
if (loaded) {
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
}
return loaded;
} catch {
return false;
} finally {
finishConfigUpdate();
}
},
deleteSkill: async (name: string) => {
startConfigUpdate("Deleting skill...");
try {
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await fetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
method: 'DELETE'
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || 'Failed to delete skill';
throw new Error(message);
}
// Skills are just files - no need to reload OpenCode
// Just refresh our local list
const loaded = await get().loadSkills();
if (loaded) {
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
}
if (get().selectedSkillName === name) {
set({ selectedSkillName: null });
}
return loaded;
} catch {
return false;
} finally {
finishConfigUpdate();
}
},
getSkillByName: (name: string) => {
const { skills } = get();
return skills.find((s) => s.name === name);
},
readSupportingFile: async (skillName: string, filePath: string) => {
try {
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `&directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await fetch(
`/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}?${queryParams.slice(1)}`
);
if (!response.ok) {
return null;
}
const data = await response.json();
return data.content ?? null;
} catch {
return null;
}
},
writeSupportingFile: async (skillName: string, filePath: string, content: string) => {
try {
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await fetch(
`/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}${queryParams}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content })
}
);
return response.ok;
} catch {
return false;
}
},
deleteSupportingFile: async (skillName: string, filePath: string) => {
try {
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await fetch(
`/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}${queryParams}`,
{ method: 'DELETE' }
);
return response.ok;
} catch {
return false;
}
},
}),
{
name: "skills-store",
storage: createJSONStorage(() => getSafeStorage()),
partialize: (state) => ({
selectedSkillName: state.selectedSkillName,
}),
},
),
{
name: "skills-store",
},
),
);
if (typeof window !== "undefined") {
window.__zustand_skills_store__ = useSkillsStore;
}
// Subscribe to config changes from other stores
let unsubscribeSkillsConfigChanges: (() => void) | null = null;
if (!unsubscribeSkillsConfigChanges) {
unsubscribeSkillsConfigChanges = subscribeToConfigChanges((event) => {
if (event.source === CONFIG_EVENT_SOURCE) {
return;
}
if (scopeMatches(event, "skills")) {
const { loadSkills } = useSkillsStore.getState();
void loadSkills();
}
});
}