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:
committed by
GitHub
parent
8aa379e313
commit
18c5b4c7b5
@@ -2,8 +2,20 @@ import React from 'react';
|
||||
import { cn, getModifierLabel } from '@/lib/utils';
|
||||
import { SIDEBAR_SECTIONS } from '@/constants/sidebar';
|
||||
import type { SidebarSection } from '@/constants/sidebar';
|
||||
import { RiArrowLeftSLine, RiCloseLine } from '@remixicon/react';
|
||||
import { RiArrowDownSLine, RiArrowLeftSLine, RiCloseLine, RiFolderLine } from '@remixicon/react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useAgentsStore } from '@/stores/useAgentsStore';
|
||||
import { useCommandsStore } from '@/stores/useCommandsStore';
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||
import { AgentsSidebar } from '@/components/sections/agents/AgentsSidebar';
|
||||
import { AgentsPage } from '@/components/sections/agents/AgentsPage';
|
||||
import { CommandsSidebar } from '@/components/sections/commands/CommandsSidebar';
|
||||
@@ -99,8 +111,61 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const setActiveProject = useProjectsStore((state) => state.setActiveProject);
|
||||
|
||||
const sortedProjects = React.useMemo(() => {
|
||||
return [...projects].sort((a, b) => (a.label || a.path).localeCompare(b.label || b.path));
|
||||
}, [projects]);
|
||||
|
||||
const activeProject = React.useMemo(() => {
|
||||
if (sortedProjects.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return sortedProjects.find((p) => p.id === activeProjectId) ?? sortedProjects[0];
|
||||
}, [activeProjectId, sortedProjects]);
|
||||
|
||||
// Format project label: kebab-case/snake_case → Title Case
|
||||
const formatProjectLabel = React.useCallback((label: string): string => {
|
||||
return label
|
||||
.replace(/[-_]/g, ' ')
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}, []);
|
||||
|
||||
const activeProjectLabel = React.useMemo(() => {
|
||||
if (!activeProject) {
|
||||
return 'Project';
|
||||
}
|
||||
const rawLabel = activeProject.label && activeProject.label.trim().length > 0
|
||||
? activeProject.label
|
||||
: (activeProject.path.split('/').filter(Boolean).pop() || activeProject.path);
|
||||
return formatProjectLabel(rawLabel);
|
||||
}, [activeProject, formatProjectLabel]);
|
||||
|
||||
const showProjectSwitcher = sortedProjects.length > 0;
|
||||
|
||||
const showTabLabels = containerWidth === 0 || containerWidth >= TAB_LABELS_MIN_WIDTH;
|
||||
|
||||
React.useEffect(() => {
|
||||
// Force reload when activeProject changes to ensure scopes update
|
||||
if (activeTab === 'agents') {
|
||||
// Small delay to allow store state to propagate if needed
|
||||
setTimeout(() => void useAgentsStore.getState().loadAgents(), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTab === 'commands') {
|
||||
setTimeout(() => void useCommandsStore.getState().loadCommands(), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTab === 'skills') {
|
||||
void useSkillsStore.getState().loadSkills();
|
||||
void useSkillsCatalogStore.getState().loadCatalog();
|
||||
}
|
||||
}, [activeProjectId, activeTab]);
|
||||
|
||||
// Update proportional width on window resize (if not manually resized)
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
@@ -341,26 +406,79 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
})}
|
||||
</div>
|
||||
|
||||
{onClose && (
|
||||
<div className={cn('flex items-center', isMobile ? '' : 'pr-3')}>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close settings"
|
||||
className={cn(
|
||||
'inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
!isMobile && 'app-region-no-drag'
|
||||
{(onClose || showProjectSwitcher) && (
|
||||
<div className={cn('flex items-center gap-2', isMobile ? '' : 'pr-3')}>
|
||||
{showProjectSwitcher && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{isMobile ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Switch project"
|
||||
title={activeProjectLabel}
|
||||
className="inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<RiFolderLine className="h-5 w-5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Switch project"
|
||||
title={activeProjectLabel}
|
||||
className={cn(
|
||||
'flex h-9 max-w-[18rem] items-center gap-1.5 bg-transparent px-2 text-foreground outline-none hover:text-foreground/80 focus-visible:ring-2 focus-visible:ring-ring',
|
||||
!isMobile && 'app-region-no-drag'
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate typography-ui-label font-medium">{activeProjectLabel}</span>
|
||||
<RiArrowDownSLine className="size-4 opacity-50" />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<RiCloseLine className="h-5 w-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Close Settings ({shortcutKey}+,)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-auto">
|
||||
<DropdownMenuRadioGroup
|
||||
value={activeProject?.id ?? ''}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
setActiveProject(value);
|
||||
}}
|
||||
>
|
||||
{sortedProjects.map((project) => {
|
||||
const rawLabel = project.label?.trim()
|
||||
? project.label.trim()
|
||||
: (project.path.split('/').filter(Boolean).pop() || project.path);
|
||||
const label = formatProjectLabel(rawLabel);
|
||||
return (
|
||||
<DropdownMenuRadioItem key={project.id} value={project.id}>
|
||||
<span className="min-w-0 truncate typography-ui">{label}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{onClose && (
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close settings"
|
||||
className={cn(
|
||||
'inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
!isMobile && 'app-region-no-drag'
|
||||
)}
|
||||
>
|
||||
<RiCloseLine className="h-5 w-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Close Settings ({shortcutKey}+,)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
RiGitBranchLine,
|
||||
RiArrowDownSLine,
|
||||
RiCheckLine,
|
||||
RiMore2Line,
|
||||
} from '@remixicon/react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
@@ -11,6 +13,14 @@ import { useAgentGroupsStore, type AgentGroup, type AgentGroupSession } from '@/
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { ChatContainer } from '@/components/chat/ChatContainer';
|
||||
import { ChatErrorBoundary } from '@/components/chat/ChatErrorBoundary';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -27,8 +37,10 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
||||
group,
|
||||
className,
|
||||
}) => {
|
||||
const { selectedSessionId, selectSession } = useAgentGroupsStore();
|
||||
const { selectedSessionId, selectSession, deleteGroupWorktree, keepOnlyGroupWorktree } = useAgentGroupsStore();
|
||||
const { setCurrentSession, currentSessionId } = useSessionStore();
|
||||
const [worktreeDialog, setWorktreeDialog] = React.useState<null | { kind: 'remove' | 'keepOnly'; path: string; label: string }>(null);
|
||||
const [isProcessing, setIsProcessing] = React.useState(false);
|
||||
|
||||
// Find the currently selected session
|
||||
const selectedSession = React.useMemo(() => {
|
||||
@@ -70,6 +82,47 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
||||
// Check if the current OpenCode session matches the selected agent group session
|
||||
const isSessionSynced = selectedSession?.id === currentSessionId;
|
||||
|
||||
const handleRemoveSelectedWorktree = React.useCallback(async () => {
|
||||
if (!selectedSession) return;
|
||||
setWorktreeDialog({ kind: 'remove', path: selectedSession.path, label: selectedSession.displayLabel });
|
||||
}, [selectedSession]);
|
||||
|
||||
const handleKeepOnlySelectedWorktree = React.useCallback(async () => {
|
||||
if (!selectedSession) return;
|
||||
setWorktreeDialog({ kind: 'keepOnly', path: selectedSession.path, label: selectedSession.displayLabel });
|
||||
}, [selectedSession]);
|
||||
|
||||
const handleConfirmWorktreeAction = React.useCallback(async () => {
|
||||
if (!worktreeDialog || isProcessing) return;
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
if (worktreeDialog.kind === 'remove') {
|
||||
toast.info('Removing worktree...');
|
||||
const ok = await deleteGroupWorktree(group.name, worktreeDialog.path);
|
||||
if (ok) {
|
||||
toast.success('Worktree removed');
|
||||
} else {
|
||||
const error = useAgentGroupsStore.getState().error;
|
||||
toast.error(error || 'Failed to remove worktree');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
toast.info('Removing other worktrees...');
|
||||
const ok = await keepOnlyGroupWorktree(group.name, worktreeDialog.path);
|
||||
if (ok) {
|
||||
toast.success('Removed other worktrees');
|
||||
} else {
|
||||
const error = useAgentGroupsStore.getState().error;
|
||||
toast.error(error || 'Failed to remove other worktrees');
|
||||
return;
|
||||
}
|
||||
}
|
||||
setWorktreeDialog(null);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [deleteGroupWorktree, group.name, isProcessing, keepOnlyGroupWorktree, worktreeDialog]);
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col bg-background', className)}>
|
||||
{/* Header */}
|
||||
@@ -90,7 +143,8 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
||||
|
||||
{/* Model Selector Dropdown */}
|
||||
{group.sessions.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -154,9 +208,64 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-10 w-10 flex-shrink-0" aria-label="Worktree actions">
|
||||
<RiMore2Line className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[220px]">
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
void handleRemoveSelectedWorktree();
|
||||
}}
|
||||
variant="destructive"
|
||||
>
|
||||
Remove this worktree
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
void handleKeepOnlySelectedWorktree();
|
||||
}}
|
||||
>
|
||||
Leave this one, remove others
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={Boolean(worktreeDialog)} onOpenChange={(open) => { if (!open) setWorktreeDialog(null); }}>
|
||||
<DialogContent className="max-w-md" keyboardAvoid>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{worktreeDialog?.kind === 'remove' ? 'Remove worktree' : 'Remove other worktrees'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{worktreeDialog?.kind === 'remove'
|
||||
? <>Remove <span className="text-foreground font-medium">{worktreeDialog?.label}</span>? This deletes all sessions in that worktree and removes the worktree itself.</>
|
||||
: <>Keep <span className="text-foreground font-medium">{worktreeDialog?.label}</span> and remove the other worktrees in <span className="text-foreground font-medium">{group.name}</span>.</>}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setWorktreeDialog(null)} disabled={isProcessing}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={worktreeDialog?.kind === 'remove' ? 'destructive' : 'default'}
|
||||
onClick={() => void handleConfirmWorktreeAction()}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? 'Working…' : worktreeDialog?.kind === 'remove' ? 'Remove' : 'Remove others'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Chat Content */}
|
||||
<div className="flex-1 min-h-0">
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from '@/components/multirun/ModelMultiSelect';
|
||||
import { BranchSelector, useBranchOptions } from '@/components/multirun/BranchSelector';
|
||||
import { AgentSelector } from '@/components/multirun/AgentSelector';
|
||||
import { isIMECompositionEvent } from '@/lib/ime';
|
||||
import type { CreateMultiRunParams, MultiRunFileAttachment } from '@/types/multirun';
|
||||
|
||||
/** Max file size in bytes (10MB) */
|
||||
@@ -23,16 +24,6 @@ const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
/** Max number of concurrent runs */
|
||||
const MAX_MODELS = 5;
|
||||
|
||||
/**
|
||||
* Detects if a keyboard event is part of IME composition.
|
||||
* Uses both isComposing and keyCode === 229 (MDN recommended).
|
||||
* WebKit may fire compositionend before keydown, causing isComposing to be false
|
||||
* while keyCode remains 229, so both checks are needed.
|
||||
*/
|
||||
const isIMECompositionEvent = (e: React.KeyboardEvent): boolean => {
|
||||
return e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229;
|
||||
};
|
||||
|
||||
/** Attached file for agent manager */
|
||||
interface AttachedFile {
|
||||
id: string;
|
||||
@@ -191,7 +182,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
if (isIMECompositionEvent(e)) return;
|
||||
|
||||
// Enter submits if valid, Shift+Enter adds newline
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isIMECompositionEvent(e)) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (isValid && !isSubmittingOrCreating) {
|
||||
handleSubmit(e as unknown as React.FormEvent);
|
||||
@@ -247,7 +238,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
onChange={setSelectedAgent}
|
||||
/>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Optional agent to use for all runs
|
||||
Defaults to your configured default agent
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,12 +10,6 @@ import { toast } from 'sonner';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -24,6 +18,12 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAgentGroupsStore, type AgentGroup } from '@/stores/useAgentGroupsStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
@@ -50,121 +50,105 @@ interface AgentGroupItemProps {
|
||||
|
||||
const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, onSelect }) => {
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = React.useState(false);
|
||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||
const deleteGroup = useAgentGroupsStore((state) => state.deleteGroup);
|
||||
|
||||
const handleDelete = async () => {
|
||||
|
||||
const handleDeleteGroup = React.useCallback(async () => {
|
||||
if (isDeleting) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const { success, deletedCount, failedCount } = await deleteGroup(group.name);
|
||||
|
||||
if (success) {
|
||||
toast.success(`Deleted agent group "${group.name}"`, {
|
||||
description: `${deletedCount} session${deletedCount !== 1 ? 's' : ''} removed with worktrees archived.`,
|
||||
});
|
||||
} else if (deletedCount > 0) {
|
||||
toast.warning(`Partially deleted agent group "${group.name}"`, {
|
||||
description: `${deletedCount} deleted, ${failedCount} failed.`,
|
||||
});
|
||||
} else {
|
||||
toast.error(`Failed to delete agent group "${group.name}"`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Failed to delete agent group "${group.name}"`);
|
||||
console.error('Delete group error:', error);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setShowDeleteConfirm(false);
|
||||
toast.info(`Deleting "${group.name}"...`);
|
||||
const ok = await deleteGroup(group.name);
|
||||
if (ok) {
|
||||
toast.success(`Deleted "${group.name}"`);
|
||||
} else {
|
||||
const error = useAgentGroupsStore.getState().error;
|
||||
toast.error(error || `Failed to delete "${group.name}"`);
|
||||
}
|
||||
};
|
||||
setIsDeleting(false);
|
||||
setConfirmOpen(false);
|
||||
}, [deleteGroup, group.name, isDeleting]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex items-center rounded-md px-1.5 py-1.5 cursor-pointer',
|
||||
isSelected ? 'dark:bg-accent/80 bg-primary/12' : 'hover:dark:bg-accent/40 hover:bg-primary/6',
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 flex-col gap-0.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
>
|
||||
<span className="truncate typography-ui-label font-normal text-foreground">
|
||||
{group.name}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="typography-micro text-muted-foreground/60 flex items-center gap-1">
|
||||
<RiGitBranchLine className="h-3 w-3" />
|
||||
{group.sessionCount} model{group.sessionCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span className="typography-micro text-muted-foreground/60">
|
||||
{formatRelativeTime(group.lastActive)}
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex items-center rounded-md px-1.5 py-1.5 cursor-pointer',
|
||||
isSelected ? 'dark:bg-accent/80 bg-primary/12' : 'hover:dark:bg-accent/40 hover:bg-primary/6',
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 flex-col gap-0.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
>
|
||||
<span className="truncate typography-ui-label font-normal text-foreground">
|
||||
{group.name}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="typography-micro text-muted-foreground/60 flex items-center gap-1">
|
||||
<RiGitBranchLine className="h-3 w-3" />
|
||||
{group.sessionCount} model{group.sessionCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span className="typography-micro text-muted-foreground/60">
|
||||
{formatRelativeTime(group.lastActive)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1.5 self-stretch">
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex h-3.5 w-[18px] items-center justify-center rounded-md text-muted-foreground transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
menuOpen && 'opacity-100',
|
||||
)}
|
||||
aria-label="Group menu"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<RiMore2Line className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[140px]">
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpen(false);
|
||||
setConfirmOpen(true);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1.5 self-stretch">
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex h-3.5 w-[18px] items-center justify-center rounded-md text-muted-foreground transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
menuOpen && 'opacity-100',
|
||||
)}
|
||||
aria-label="Group menu"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<RiMore2Line className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[140px]">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpen(false);
|
||||
setShowDeleteConfirm(true);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<DialogContent showCloseButton={!isDeleting}>
|
||||
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent className="max-w-md" keyboardAvoid>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Agent Group</DialogTitle>
|
||||
<DialogTitle>Delete agent group</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{group.name}"? This will remove {group.sessionCount} session{group.sessionCount !== 1 ? 's' : ''} and archive their worktrees. This action cannot be undone.
|
||||
Delete <span className="text-foreground font-medium">{group.name}</span>? This removes all worktrees and sessions in this group.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Button variant="outline" onClick={() => setConfirmOpen(false)} disabled={isDeleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
<Button variant="destructive" onClick={() => void handleDeleteGroup()} disabled={isDeleting}>
|
||||
{isDeleting ? 'Deleting…' : 'Delete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@ import { AgentGroupDetail } from './AgentGroupDetail';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAgentGroupsStore } from '@/stores/useAgentGroupsStore';
|
||||
import { useMultiRunStore } from '@/stores/useMultiRunStore';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
|
||||
import type { CreateMultiRunParams } from '@/types/multirun';
|
||||
|
||||
interface AgentManagerViewProps {
|
||||
@@ -13,6 +17,25 @@ interface AgentManagerViewProps {
|
||||
}
|
||||
|
||||
export const AgentManagerView: React.FC<AgentManagerViewProps> = ({ className }) => {
|
||||
const isVSCodeRuntime = Boolean(
|
||||
(typeof window !== 'undefined'
|
||||
? (window as unknown as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isVSCode?: boolean } } })
|
||||
.__OPENCHAMBER_RUNTIME_APIS__?.runtime?.isVSCode
|
||||
: false)
|
||||
);
|
||||
const [connectionStatus, setConnectionStatus] = React.useState<'connecting' | 'connected' | 'error' | 'disconnected'>(
|
||||
() =>
|
||||
(typeof window !== 'undefined'
|
||||
? (window as unknown as { __OPENCHAMBER_CONNECTION__?: { status?: string } }).__OPENCHAMBER_CONNECTION__?.status as
|
||||
'connecting' | 'connected' | 'error' | 'disconnected' | undefined
|
||||
: 'connecting') || 'connecting'
|
||||
);
|
||||
const configInitialized = useConfigStore((state) => state.isInitialized);
|
||||
const initializeApp = useConfigStore((state) => state.initializeApp);
|
||||
const loadSessions = useSessionStore((state) => state.loadSessions);
|
||||
const setDirectory = useDirectoryStore((state) => state.setDirectory);
|
||||
const bootstrapAttemptAt = React.useRef<number>(0);
|
||||
|
||||
const {
|
||||
selectedGroupName,
|
||||
selectGroup,
|
||||
@@ -22,6 +45,86 @@ export const AgentManagerView: React.FC<AgentManagerViewProps> = ({ className })
|
||||
|
||||
const { createMultiRun, isLoading: isCreatingMultiRun } = useMultiRunStore();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isVSCodeRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current =
|
||||
(typeof window !== 'undefined'
|
||||
? (window as unknown as { __OPENCHAMBER_CONNECTION__?: { status?: string } }).__OPENCHAMBER_CONNECTION__?.status
|
||||
: undefined) as 'connecting' | 'connected' | 'error' | 'disconnected' | undefined;
|
||||
if (current === 'connected' || current === 'connecting' || current === 'error' || current === 'disconnected') {
|
||||
setConnectionStatus(current);
|
||||
}
|
||||
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ status?: string; error?: string }>).detail;
|
||||
const status = detail?.status;
|
||||
if (status === 'connected' || status === 'connecting' || status === 'error' || status === 'disconnected') {
|
||||
setConnectionStatus(status);
|
||||
}
|
||||
};
|
||||
window.addEventListener('openchamber:connection-status', handler as EventListener);
|
||||
return () => window.removeEventListener('openchamber:connection-status', handler as EventListener);
|
||||
}, [isVSCodeRuntime]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isVSCodeRuntime || connectionStatus !== 'connected') {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - bootstrapAttemptAt.current < 750) {
|
||||
return;
|
||||
}
|
||||
bootstrapAttemptAt.current = now;
|
||||
|
||||
const workspaceFolder = (typeof window !== 'undefined'
|
||||
? (window as unknown as { __VSCODE_CONFIG__?: { workspaceFolder?: unknown } }).__VSCODE_CONFIG__?.workspaceFolder
|
||||
: null);
|
||||
|
||||
if (typeof workspaceFolder === 'string' && workspaceFolder.trim().length > 0) {
|
||||
try {
|
||||
setDirectory(workspaceFolder, { showOverlay: false });
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
|
||||
const runBootstrap = async () => {
|
||||
try {
|
||||
if (!configInitialized) {
|
||||
await initializeApp();
|
||||
}
|
||||
|
||||
const configState = useConfigStore.getState();
|
||||
if (
|
||||
!configState.isInitialized ||
|
||||
!configState.isConnected ||
|
||||
configState.providers.length === 0 ||
|
||||
configState.agents.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await loadSessions();
|
||||
|
||||
if (streamDebugEnabled()) {
|
||||
console.log('[OpenChamber][VSCode][agentManager] bootstrap complete', {
|
||||
providers: configState.providers.length,
|
||||
agents: configState.agents.length,
|
||||
sessions: useSessionStore.getState().sessions.length,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
};
|
||||
|
||||
void runBootstrap();
|
||||
}, [connectionStatus, configInitialized, initializeApp, isVSCodeRuntime, loadSessions, setDirectory]);
|
||||
|
||||
const handleGroupSelect = React.useCallback((groupName: string) => {
|
||||
selectGroup(groupName);
|
||||
}, [selectGroup]);
|
||||
@@ -38,10 +141,29 @@ export const AgentManagerView: React.FC<AgentManagerViewProps> = ({ className })
|
||||
|
||||
if (result) {
|
||||
toast.success(`Agent group "${params.name}" created with ${result.sessionIds.length} session(s)`);
|
||||
// Reload groups to pick up the new worktrees and sessions
|
||||
await loadGroups();
|
||||
// Select the newly created group
|
||||
selectGroup(params.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 50));
|
||||
const groupSlug = result.groupSlug;
|
||||
|
||||
const waitForGroup = async (attempts = 6) => {
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
await loadGroups();
|
||||
const groupsState = useAgentGroupsStore.getState();
|
||||
if (groupsState.groups.some((group) => group.name === groupSlug)) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Refresh sessions + groups and wait briefly for OpenCode to surface the new worktree sessions.
|
||||
try {
|
||||
await useSessionStore.getState().loadSessions();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
await waitForGroup();
|
||||
selectGroup(groupSlug);
|
||||
} else {
|
||||
const error = useMultiRunStore.getState().error;
|
||||
toast.error(error || 'Failed to create agent group');
|
||||
|
||||
Reference in New Issue
Block a user