/** * Composable for managing Agent Projects. * Handles project CRUD, selection, and persistence. */ import { ref, computed, watch } from 'vue'; import type { AgentProject, AgentStoredMessage } from 'chrome-mcp-shared'; const STORAGE_KEY_SELECTED_PROJECT = 'agent-selected-project-id'; interface PathValidationResult { valid: boolean; absolute: string; exists: boolean; needsCreation: boolean; error?: string; } /** * Normalize path for comparison (handle trailing slashes and separators). */ function normalizePathForComparison(path: string): string { // Remove trailing slashes and normalize separators return path .trim() .replace(/[/\\]+$/, '') .replace(/\\/g, '/') .toLowerCase(); } export interface UseAgentProjectsOptions { getServerPort: () => number | null; ensureServer: () => Promise; onHistoryLoaded?: (messages: AgentStoredMessage[]) => void; } export function useAgentProjects(options: UseAgentProjectsOptions) { // State const projects = ref([]); const selectedProjectId = ref(''); const isLoadingProjects = ref(false); const showCreateProject = ref(false); const newProjectName = ref(''); const newProjectRootPath = ref(''); const isCreatingProject = ref(false); const projectError = ref(null); // Computed const selectedProject = computed(() => { return projects.value.find((p) => p.id === selectedProjectId.value) || null; }); const canCreateProject = computed(() => { return newProjectName.value.trim().length > 0 && newProjectRootPath.value.trim().length > 0; }); // Load selected project from storage async function loadSelectedProjectId(): Promise { try { const result = await chrome.storage.local.get(STORAGE_KEY_SELECTED_PROJECT); if (result[STORAGE_KEY_SELECTED_PROJECT]) { selectedProjectId.value = result[STORAGE_KEY_SELECTED_PROJECT]; } } catch (error) { console.error('Failed to load selected project ID:', error); } } // Save selected project to storage async function saveSelectedProjectId(): Promise { try { await chrome.storage.local.set({ [STORAGE_KEY_SELECTED_PROJECT]: selectedProjectId.value, }); } catch (error) { console.error('Failed to save selected project ID:', error); } } // Fetch projects from server async function fetchProjects(): Promise { const serverPort = options.getServerPort(); if (!serverPort) return; isLoadingProjects.value = true; try { const url = `http://127.0.0.1:${serverPort}/agent/projects`; const response = await fetch(url); if (response.ok) { const data = await response.json(); projects.value = data.projects || []; } } catch (error) { console.error('Failed to fetch projects:', error); } finally { isLoadingProjects.value = false; } } // Refresh projects async function refreshProjects(): Promise { const ready = await options.ensureServer(); if (!ready) return; await fetchProjects(); } // Track pending history load with nonce to prevent A→B→A race conditions let historyLoadNonce = 0; /** * Load chat history for a project with race-condition protection. * Uses a nonce to handle A→B→A scenarios. */ async function loadChatHistory(projectId: string): Promise { const serverPort = options.getServerPort(); if (!serverPort || !projectId) return; // Increment nonce - any subsequent load will invalidate this one const myNonce = ++historyLoadNonce; const isStillValid = (): boolean => { return myNonce === historyLoadNonce && selectedProjectId.value === projectId; }; try { const url = `http://127.0.0.1:${serverPort}/agent/chat/${encodeURIComponent(projectId)}/messages?limit=100`; const response = await fetch(url); if (!isStillValid()) return; if (response.ok) { const result = await response.json(); if (!isStillValid()) return; // Server returns { success, data: messages[], totalCount, pagination } const stored = result.data || []; options.onHistoryLoaded?.(stored); } } catch (error) { console.error('Failed to load chat history:', error); } } // Validate path before creating project async function validatePath(rootPath: string): Promise { const serverPort = options.getServerPort(); if (!serverPort) return null; try { const url = `http://127.0.0.1:${serverPort}/agent/projects/validate-path`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rootPath }), }); if (!response.ok) { const text = await response.text().catch(() => ''); throw new Error(text || `Validation failed: HTTP ${response.status}`); } return await response.json(); } catch (error) { console.error('Failed to validate path:', error); return null; } } // Create project async function createProject(): Promise { const name = newProjectName.value.trim(); const rootPath = newProjectRootPath.value.trim(); if (!name || !rootPath) return null; const ready = await options.ensureServer(); const serverPort = options.getServerPort(); if (!ready || !serverPort) { projectError.value = 'Agent server is not available.'; return null; } isCreatingProject.value = true; projectError.value = null; try { // Step 1: Validate the path const validation = await validatePath(rootPath); if (!validation) { projectError.value = 'Failed to validate path'; return null; } if (!validation.valid) { projectError.value = validation.error || 'Invalid path'; return null; } // Step 2: If directory doesn't exist, ask user for confirmation let allowCreate = false; if (validation.needsCreation) { const confirmed = confirm( `目录 "${validation.absolute}" 不存在,是否创建?\n\nThe directory "${validation.absolute}" does not exist. Create it?`, ); if (!confirmed) { return null; } allowCreate = true; } // Step 3: Create the project const url = `http://127.0.0.1:${serverPort}/agent/projects`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, rootPath, allowCreate }), }); if (!response.ok) { const text = await response.text().catch(() => ''); throw new Error(text || `HTTP ${response.status}`); } const payload = await response.json(); const project = payload?.project as AgentProject | undefined; if (project?.id) { // Update local state const others = projects.value.filter((p) => p.id !== project.id); projects.value = [...others, project]; selectedProjectId.value = project.id; await saveSelectedProjectId(); await loadChatHistory(project.id); // Clear form newProjectName.value = ''; newProjectRootPath.value = ''; showCreateProject.value = false; return project; } else { projectError.value = 'Project created but response is invalid.'; return null; } } catch (error: unknown) { console.error('Failed to create project:', error); projectError.value = error instanceof Error ? error.message : 'Failed to create project.'; return null; } finally { isCreatingProject.value = false; } } // Toggle create project form function toggleCreateProject(): void { showCreateProject.value = !showCreateProject.value; if (!showCreateProject.value) { newProjectName.value = ''; newProjectRootPath.value = ''; projectError.value = null; } } // Get default project root path for a project name async function getDefaultProjectRoot(projectName: string): Promise { const serverPort = options.getServerPort(); if (!serverPort || !projectName.trim()) return null; try { const url = `http://127.0.0.1:${serverPort}/agent/projects/default-root`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectName: projectName.trim() }), }); if (response.ok) { const data = await response.json(); return data.path || null; } return null; } catch (error) { console.error('Failed to get default project root:', error); return null; } } // Open directory picker dialog async function pickDirectory(): Promise { const ready = await options.ensureServer(); const serverPort = options.getServerPort(); if (!ready || !serverPort) { projectError.value = 'Server not available'; return null; } try { const url = `http://127.0.0.1:${serverPort}/agent/projects/pick-directory`; const response = await fetch(url, { method: 'POST' }); // Handle HTTP errors (e.g., 404 means server version mismatch) if (!response.ok) { if (response.status === 404) { projectError.value = 'Directory picker not available. Please rebuild and restart the native server.'; } else { projectError.value = `Server error: HTTP ${response.status}`; } return null; } const data = await response.json(); if (data.success && data.path) { return data.path; } else if (data.cancelled) { return null; // User cancelled, not an error } else { projectError.value = data.error || 'Failed to open directory picker'; return null; } } catch (error) { console.error('Failed to open directory picker:', error); projectError.value = 'Failed to open directory picker'; return null; } } // Ensure default project exists (auto-create if no projects) async function ensureDefaultProject(): Promise { const ready = await options.ensureServer(); const serverPort = options.getServerPort(); if (!ready || !serverPort) return null; try { // First fetch current projects await fetchProjects(); // If there are already projects, no need to create default if (projects.value.length > 0) { return null; } // Get default workspace directory from server const defaultRootUrl = `http://127.0.0.1:${serverPort}/agent/projects/default-root`; const defaultRootResponse = await fetch(defaultRootUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectName: 'default' }), }); const defaultRootData = await defaultRootResponse.json(); const defaultRoot = defaultRootData.path; if (!defaultRoot) { console.error('Failed to get default project root'); return null; } // Create default project const createUrl = `http://127.0.0.1:${serverPort}/agent/projects`; const createResponse = await fetch(createUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Default', rootPath: defaultRoot, allowCreate: true, }), }); if (!createResponse.ok) { const text = await createResponse.text().catch(() => ''); console.error('Failed to create default project:', text); return null; } const payload = await createResponse.json(); const project = payload?.project as AgentProject | undefined; if (project?.id) { projects.value = [project]; selectedProjectId.value = project.id; await saveSelectedProjectId(); return project; } return null; } catch (error) { console.error('Failed to ensure default project:', error); return null; } } // Create project from a directory path (used when user picks a directory) async function createProjectFromPath( rootPath: string, name: string, ): Promise { const ready = await options.ensureServer(); const serverPort = options.getServerPort(); if (!ready || !serverPort) { projectError.value = 'Agent server is not available.'; return null; } projectError.value = null; try { // Validate the path first const validation = await validatePath(rootPath); if (!validation) { projectError.value = 'Failed to validate path'; return null; } if (!validation.valid) { projectError.value = validation.error || 'Invalid path'; return null; } // Check if project with same path already exists const normalizedPath = normalizePathForComparison(validation.absolute); const existingProject = projects.value.find( (p) => normalizePathForComparison(p.rootPath) === normalizedPath, ); if (existingProject) { // Project already exists - select it instead of creating a new one const shouldSwitch = confirm( `目录 "${validation.absolute}" 已存在对应的项目:${existingProject.name}\n\n` + `是否切换到该项目?\n\n` + `A project already exists for "${validation.absolute}": ${existingProject.name}\n` + `Switch to that project?`, ); if (shouldSwitch) { selectedProjectId.value = existingProject.id; await saveSelectedProjectId(); await loadChatHistory(existingProject.id); return existingProject; } // User declined to switch, return null to indicate no action taken return null; } // If directory doesn't exist, ask user for confirmation let allowCreate = false; if (validation.needsCreation) { const confirmed = confirm( `目录 "${validation.absolute}" 不存在,是否创建?\n\nThe directory "${validation.absolute}" does not exist. Create it?`, ); if (!confirmed) { return null; } allowCreate = true; } // Create the project const url = `http://127.0.0.1:${serverPort}/agent/projects`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, rootPath, allowCreate }), }); if (!response.ok) { const text = await response.text().catch(() => ''); throw new Error(text || `HTTP ${response.status}`); } const payload = await response.json(); const project = payload?.project as AgentProject | undefined; if (project?.id) { // Update local state const others = projects.value.filter((p) => p.id !== project.id); projects.value = [...others, project]; selectedProjectId.value = project.id; await saveSelectedProjectId(); await loadChatHistory(project.id); return project; } else { projectError.value = 'Project created but response is invalid.'; return null; } } catch (error: unknown) { console.error('Failed to create project from path:', error); projectError.value = error instanceof Error ? error.message : 'Failed to create project.'; return null; } } // Handle project change async function handleProjectChanged(): Promise { await saveSelectedProjectId(); if (selectedProjectId.value) { await loadChatHistory(selectedProjectId.value); } } // Save project preference (CLI, model, useCcr, enableChromeMcp) async function saveProjectPreference( cli?: string, model?: string, useCcr?: boolean, enableChromeMcp?: boolean, ): Promise { const project = selectedProject.value; const serverPort = options.getServerPort(); if (!project || !serverPort) return; try { const url = `http://127.0.0.1:${serverPort}/agent/projects`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: project.id, name: project.name, rootPath: project.rootPath, // Normalize and allow empty string (means "Auto/Default") preferredCli: cli?.trim() ?? project.preferredCli, selectedModel: model?.trim() ?? project.selectedModel, useCcr: useCcr ?? project.useCcr, enableChromeMcp: enableChromeMcp ?? project.enableChromeMcp, }), }); // Update local project state if successful if (response.ok) { const payload = await response.json(); const updatedProject = payload?.project as AgentProject | undefined; if (updatedProject?.id) { const index = projects.value.findIndex((p) => p.id === updatedProject.id); if (index !== -1) { projects.value[index] = updatedProject; } } } } catch (error) { console.error('Failed to save project preference:', error); } } return { // State projects, selectedProjectId, isLoadingProjects, showCreateProject, newProjectName, newProjectRootPath, isCreatingProject, projectError, // Computed selectedProject, canCreateProject, // Methods loadSelectedProjectId, saveSelectedProjectId, fetchProjects, refreshProjects, loadChatHistory, createProject, toggleCreateProject, handleProjectChanged, saveProjectPreference, getDefaultProjectRoot, pickDirectory, ensureDefaultProject, createProjectFromPath, }; }