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
@@ -30,6 +30,7 @@ import { toast } from 'sonner';
|
||||
import { useFileStore } from '@/stores/fileStore';
|
||||
import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { isIMECompositionEvent } from '@/lib/ime';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -47,14 +48,68 @@ interface ChatInputProps {
|
||||
|
||||
const isPrimaryMode = (mode?: string) => mode === 'primary' || mode === 'all' || mode === undefined || mode === null;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
type PermissionAction = 'allow' | 'ask' | 'deny';
|
||||
type PermissionRule = { permission: string; pattern: string; action: PermissionAction };
|
||||
|
||||
const asPermissionRuleset = (value: unknown): PermissionRule[] | null => {
|
||||
if (!Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const rules: PermissionRule[] = [];
|
||||
for (const entry of value) {
|
||||
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;
|
||||
}
|
||||
rules.push({ permission: candidate.permission, pattern: candidate.pattern, action: candidate.action });
|
||||
}
|
||||
return rules;
|
||||
};
|
||||
|
||||
const resolveWildcardPermissionAction = (ruleset: unknown, permission: string): PermissionAction | undefined => {
|
||||
const rules = asPermissionRuleset(ruleset);
|
||||
if (!rules || rules.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (let i = rules.length - 1; i >= 0; i -= 1) {
|
||||
const rule = rules[i];
|
||||
if (rule.permission === permission && rule.pattern === '*') {
|
||||
return rule.action;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = rules.length - 1; i >= 0; i -= 1) {
|
||||
const rule = rules[i];
|
||||
if (rule.permission === '*' && rule.pattern === '*') {
|
||||
return rule.action;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const buildPermissionActionMap = (ruleset: unknown, permission: string): Record<string, PermissionAction | undefined> | undefined => {
|
||||
const rules = asPermissionRuleset(ruleset);
|
||||
if (!rules || rules.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const map: Record<string, PermissionAction | undefined> = {};
|
||||
for (const rule of rules) {
|
||||
if (rule.permission !== permission) {
|
||||
continue;
|
||||
}
|
||||
map[rule.pattern] = rule.action;
|
||||
}
|
||||
|
||||
return Object.keys(map).length > 0 ? map : undefined;
|
||||
};
|
||||
|
||||
export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => {
|
||||
@@ -184,19 +239,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
}, [agents, currentAgentName]);
|
||||
|
||||
const agentDefaultEditMode = React.useMemo<EditPermissionMode>(() => {
|
||||
const agentPermissionRaw = currentAgent?.permission?.edit;
|
||||
let defaultMode: EditPermissionMode = 'ask';
|
||||
|
||||
if (agentPermissionRaw === 'allow' || agentPermissionRaw === 'ask' || agentPermissionRaw === 'deny' || agentPermissionRaw === 'full') {
|
||||
defaultMode = agentPermissionRaw;
|
||||
if (!currentAgent) {
|
||||
return 'deny';
|
||||
}
|
||||
|
||||
const editToolConfigured = currentAgent ? (currentAgent.tools?.['edit'] !== false) : false;
|
||||
if (!currentAgent || !editToolConfigured) {
|
||||
defaultMode = 'deny';
|
||||
}
|
||||
|
||||
return defaultMode;
|
||||
const action = resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'ask';
|
||||
return action;
|
||||
}, [currentAgent]);
|
||||
|
||||
const sessionAgentEditOverride = useSessionStore(
|
||||
@@ -209,8 +257,20 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
}, [currentSessionId, currentAgentName])
|
||||
);
|
||||
|
||||
const agentWebfetchPermission = currentAgent?.permission?.webfetch;
|
||||
const agentBashPermission = currentAgent?.permission?.bash as BashPermissionSetting | undefined;
|
||||
const agentWebfetchPermission = React.useMemo(() => {
|
||||
if (!currentAgent) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveWildcardPermissionAction(currentAgent.permission, 'webfetch');
|
||||
}, [currentAgent]);
|
||||
|
||||
const agentBashPermission = React.useMemo<BashPermissionSetting | undefined>(() => {
|
||||
if (!currentAgent) {
|
||||
return undefined;
|
||||
}
|
||||
const map = buildPermissionActionMap(currentAgent.permission, 'bash');
|
||||
return map ? (map as BashPermissionSetting) : undefined;
|
||||
}, [currentAgent]);
|
||||
|
||||
const permissionUiState = React.useMemo(() => calculateEditPermissionUIState({
|
||||
agentDefaultEditMode,
|
||||
@@ -486,8 +546,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
}, [sessionPhase, queuedMessages.length, currentSessionId, currentProviderId, currentModelId, sessionAbortFlags]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Early return during IME composition to prevent interference with autocomplete
|
||||
// Uses keyCode === 229 fallback for WebKit where compositionend fires before keydown
|
||||
// Early return during IME composition to prevent interference with autocomplete.
|
||||
// Uses keyCode === 229 fallback for WebKit where compositionend fires before keydown.
|
||||
if (isIMECompositionEvent(e)) return;
|
||||
|
||||
if (showCommandAutocomplete && commandRef.current) {
|
||||
@@ -521,7 +581,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
}
|
||||
|
||||
// Handle Enter/Ctrl+Enter based on queue mode
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isMobile && !isIMECompositionEvent(e)) {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isMobile) {
|
||||
e.preventDefault();
|
||||
|
||||
const isCtrlEnter = e.ctrlKey || e.metaKey;
|
||||
|
||||
@@ -3,14 +3,14 @@ import type { Message, Part } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import ChatMessage from './ChatMessage';
|
||||
import { PermissionCard } from './PermissionCard';
|
||||
import type { Permission } from '@/types/permission';
|
||||
import type { PermissionRequest } from '@/types/permission';
|
||||
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
import { filterSyntheticParts } from '@/lib/messages/synthetic';
|
||||
import { useTurnGrouping } from './hooks/useTurnGrouping';
|
||||
|
||||
interface MessageListProps {
|
||||
messages: { info: Message; parts: Part[] }[];
|
||||
permissions: Permission[];
|
||||
permissions: PermissionRequest[];
|
||||
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||
hasMoreAbove: boolean;
|
||||
|
||||
@@ -55,6 +55,70 @@ type ProviderModel = Record<string, unknown> & { id?: string; name?: string };
|
||||
|
||||
const isPrimaryMode = (mode?: string) => mode === 'primary' || mode === 'all' || mode === undefined || mode === null;
|
||||
|
||||
type PermissionAction = 'allow' | 'ask' | 'deny';
|
||||
type PermissionRule = { permission: string; pattern: string; action: PermissionAction };
|
||||
|
||||
const asPermissionRuleset = (value: unknown): PermissionRule[] | null => {
|
||||
if (!Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const rules: PermissionRule[] = [];
|
||||
for (const entry of value) {
|
||||
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;
|
||||
}
|
||||
rules.push({ permission: candidate.permission, pattern: candidate.pattern, action: candidate.action });
|
||||
}
|
||||
return rules;
|
||||
};
|
||||
|
||||
const resolveWildcardPermissionAction = (ruleset: unknown, permission: string): PermissionAction | undefined => {
|
||||
const rules = asPermissionRuleset(ruleset);
|
||||
if (!rules || rules.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (let i = rules.length - 1; i >= 0; i -= 1) {
|
||||
const rule = rules[i];
|
||||
if (rule.permission === permission && rule.pattern === '*') {
|
||||
return rule.action;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = rules.length - 1; i >= 0; i -= 1) {
|
||||
const rule = rules[i];
|
||||
if (rule.permission === '*' && rule.pattern === '*') {
|
||||
return rule.action;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const buildPermissionActionMap = (ruleset: unknown, permission: string): Record<string, PermissionAction | undefined> | undefined => {
|
||||
const rules = asPermissionRuleset(ruleset);
|
||||
if (!rules || rules.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const map: Record<string, PermissionAction | undefined> = {};
|
||||
for (const rule of rules) {
|
||||
if (rule.permission !== permission) {
|
||||
continue;
|
||||
}
|
||||
map[rule.pattern] = rule.action;
|
||||
}
|
||||
|
||||
return Object.keys(map).length > 0 ? map : undefined;
|
||||
};
|
||||
|
||||
interface CapabilityDefinition {
|
||||
key: 'tool_call' | 'reasoning';
|
||||
icon: IconComponent;
|
||||
@@ -314,19 +378,29 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
}, [desktopModelQuery]);
|
||||
|
||||
const currentAgent = getCurrentAgent?.();
|
||||
const agentPermissionRaw = currentAgent?.permission?.edit;
|
||||
let agentDefaultEditMode: EditPermissionMode = 'ask';
|
||||
if (agentPermissionRaw === 'allow' || agentPermissionRaw === 'ask' || agentPermissionRaw === 'deny' || agentPermissionRaw === 'full') {
|
||||
agentDefaultEditMode = agentPermissionRaw;
|
||||
}
|
||||
|
||||
const editToolConfigured = currentAgent ? (currentAgent.tools?.['edit'] !== false) : false;
|
||||
if (!currentAgent || !editToolConfigured) {
|
||||
agentDefaultEditMode = 'deny';
|
||||
}
|
||||
const agentDefaultEditMode = React.useMemo<EditPermissionMode>(() => {
|
||||
if (!currentAgent) {
|
||||
return 'deny';
|
||||
}
|
||||
const action = resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'ask';
|
||||
return action;
|
||||
}, [currentAgent]);
|
||||
|
||||
const agentWebfetchPermission = currentAgent?.permission?.webfetch;
|
||||
const agentBashPermission = currentAgent?.permission?.bash as BashPermissionSetting | undefined;
|
||||
const agentWebfetchPermission = React.useMemo(() => {
|
||||
if (!currentAgent) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveWildcardPermissionAction(currentAgent.permission, 'webfetch');
|
||||
}, [currentAgent]);
|
||||
|
||||
const agentBashPermission = React.useMemo<BashPermissionSetting | undefined>(() => {
|
||||
if (!currentAgent) {
|
||||
return undefined;
|
||||
}
|
||||
const map = buildPermissionActionMap(currentAgent.permission, 'bash');
|
||||
return map ? (map as BashPermissionSetting) : undefined;
|
||||
}, [currentAgent]);
|
||||
|
||||
const permissionUiState = React.useMemo(() => calculateEditPermissionUIState({
|
||||
agentDefaultEditMode,
|
||||
@@ -994,27 +1068,27 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
const renderMobileAgentTooltip = () => {
|
||||
if (!isCompact || mobileTooltipOpen !== 'agent' || !currentAgent) return null;
|
||||
|
||||
const enabledTools = Object.entries(currentAgent.tools || {})
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([tool]) => tool)
|
||||
.sort();
|
||||
|
||||
const hasCustomPrompt = Boolean(currentAgent.prompt && currentAgent.prompt.trim().length > 0);
|
||||
const hasModelConfig = currentAgent.model?.providerID && currentAgent.model?.modelID;
|
||||
const hasTemperatureOrTopP = currentAgent.temperature !== undefined || currentAgent.topP !== undefined;
|
||||
|
||||
const getPermissionIcon = (permission?: string) => {
|
||||
const mode: EditPermissionMode =
|
||||
permission === 'full' || permission === 'allow' || permission === 'deny' ? permission : 'ask';
|
||||
return renderEditModeIcon(mode, 'h-3.5 w-3.5');
|
||||
const summarizePermission = (permissionName: string): { mode: EditPermissionMode; label: string } => {
|
||||
const rules = asPermissionRuleset(currentAgent.permission) ?? [];
|
||||
const hasCustom = rules.some((rule) => rule.permission === permissionName && rule.pattern !== '*');
|
||||
const action = resolveWildcardPermissionAction(rules, permissionName) ?? 'ask';
|
||||
|
||||
if (hasCustom) {
|
||||
return { mode: 'ask', label: 'Custom' };
|
||||
}
|
||||
|
||||
if (action === 'allow') return { mode: 'allow', label: 'Allow' };
|
||||
if (action === 'deny') return { mode: 'deny', label: 'Deny' };
|
||||
return { mode: 'ask', label: 'Ask' };
|
||||
};
|
||||
|
||||
const getPermissionLabel = (permission?: string) => {
|
||||
if (permission === 'full') return 'Full';
|
||||
if (permission === 'allow') return 'Allow';
|
||||
if (permission === 'deny') return 'Deny';
|
||||
return 'Ask';
|
||||
};
|
||||
const editPermissionSummary = summarizePermission('edit');
|
||||
const bashPermissionSummary = summarizePermission('bash');
|
||||
const webfetchPermissionSummary = summarizePermission('webfetch');
|
||||
|
||||
return (
|
||||
<MobileOverlayPanel
|
||||
@@ -1066,24 +1140,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{}
|
||||
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
|
||||
<div className="typography-micro text-muted-foreground mb-1">Tools</div>
|
||||
{enabledTools.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1 leading-tight">
|
||||
{enabledTools.map((tool) => (
|
||||
<span
|
||||
key={tool}
|
||||
className="inline-flex items-center rounded-lg bg-muted/60 px-1.5 py-0.5 typography-meta text-foreground"
|
||||
>
|
||||
{tool}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="typography-meta text-muted-foreground">All enabled</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
|
||||
@@ -1092,27 +1148,27 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="typography-meta text-muted-foreground/80">Edit</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{getPermissionIcon(currentAgent.permission?.edit)}
|
||||
{renderEditModeIcon(editPermissionSummary.mode, 'h-3.5 w-3.5')}
|
||||
<span className="typography-meta font-medium text-foreground">
|
||||
{getPermissionLabel(currentAgent.permission?.edit)}
|
||||
{editPermissionSummary.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="typography-meta text-muted-foreground/80">Bash</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{getPermissionIcon(typeof currentAgent.permission?.bash === 'string' ? currentAgent.permission.bash : undefined)}
|
||||
{renderEditModeIcon(bashPermissionSummary.mode, 'h-3.5 w-3.5')}
|
||||
<span className="typography-meta font-medium text-foreground">
|
||||
{getPermissionLabel(typeof currentAgent.permission?.bash === 'string' ? currentAgent.permission.bash : undefined)}
|
||||
{bashPermissionSummary.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="typography-meta text-muted-foreground/80">WebFetch</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{getPermissionIcon(currentAgent.permission?.webfetch)}
|
||||
{renderEditModeIcon(webfetchPermissionSummary.mode, 'h-3.5 w-3.5')}
|
||||
<span className="typography-meta font-medium text-foreground">
|
||||
{getPermissionLabel(currentAgent.permission?.webfetch)}
|
||||
{webfetchPermissionSummary.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1983,27 +2039,27 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const enabledTools = Object.entries(currentAgent.tools || {})
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([tool]) => tool)
|
||||
.sort();
|
||||
|
||||
const hasCustomPrompt = Boolean(currentAgent.prompt && currentAgent.prompt.trim().length > 0);
|
||||
const hasModelConfig = currentAgent.model?.providerID && currentAgent.model?.modelID;
|
||||
const hasTemperatureOrTopP = currentAgent.temperature !== undefined || currentAgent.topP !== undefined;
|
||||
|
||||
const getPermissionIcon = (permission?: string) => {
|
||||
const mode: EditPermissionMode =
|
||||
permission === 'full' || permission === 'allow' || permission === 'deny' ? permission : 'ask';
|
||||
return renderEditModeIcon(mode, 'h-3.5 w-3.5');
|
||||
const summarizePermission = (permissionName: string): { mode: EditPermissionMode; label: string } => {
|
||||
const rules = asPermissionRuleset(currentAgent.permission) ?? [];
|
||||
const hasCustom = rules.some((rule) => rule.permission === permissionName && rule.pattern !== '*');
|
||||
const action = resolveWildcardPermissionAction(rules, permissionName) ?? 'ask';
|
||||
|
||||
if (hasCustom) {
|
||||
return { mode: 'ask', label: 'Custom' };
|
||||
}
|
||||
|
||||
if (action === 'allow') return { mode: 'allow', label: 'Allow' };
|
||||
if (action === 'deny') return { mode: 'deny', label: 'Deny' };
|
||||
return { mode: 'ask', label: 'Ask' };
|
||||
};
|
||||
|
||||
const getPermissionLabel = (permission?: string) => {
|
||||
if (permission === 'full') return 'Full';
|
||||
if (permission === 'allow') return 'Allow';
|
||||
if (permission === 'deny') return 'Deny';
|
||||
return 'Ask';
|
||||
};
|
||||
const editPermissionSummary = summarizePermission('edit');
|
||||
const bashPermissionSummary = summarizePermission('bash');
|
||||
const webfetchPermissionSummary = summarizePermission('webfetch');
|
||||
|
||||
return (
|
||||
<TooltipContent align="start" sideOffset={8} className="max-w-[280px]">
|
||||
@@ -2053,50 +2109,33 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Tools</span>
|
||||
{enabledTools.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1 leading-tight">
|
||||
{enabledTools.map((tool) => (
|
||||
<span
|
||||
key={tool}
|
||||
className="inline-flex items-center rounded-lg bg-muted/60 px-1.5 py-0.5 typography-meta text-foreground"
|
||||
>
|
||||
{tool}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="typography-meta text-muted-foreground">All enabled</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Permissions</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="typography-meta text-muted-foreground/80 w-16">Edit</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{getPermissionIcon(currentAgent.permission?.edit)}
|
||||
{renderEditModeIcon(editPermissionSummary.mode, 'h-3.5 w-3.5')}
|
||||
<span className="typography-meta font-medium text-foreground w-12">
|
||||
{getPermissionLabel(currentAgent.permission?.edit)}
|
||||
{editPermissionSummary.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="typography-meta text-muted-foreground/80 w-16">Bash</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{getPermissionIcon(typeof currentAgent.permission?.bash === 'string' ? currentAgent.permission.bash : undefined)}
|
||||
{renderEditModeIcon(bashPermissionSummary.mode, 'h-3.5 w-3.5')}
|
||||
<span className="typography-meta font-medium text-foreground w-12">
|
||||
{getPermissionLabel(typeof currentAgent.permission?.bash === 'string' ? currentAgent.permission.bash : undefined)}
|
||||
{bashPermissionSummary.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="typography-meta text-muted-foreground/80 w-16">WebFetch</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{getPermissionIcon(currentAgent.permission?.webfetch)}
|
||||
{renderEditModeIcon(webfetchPermissionSummary.mode, 'h-3.5 w-3.5')}
|
||||
<span className="typography-meta font-medium text-foreground w-12">
|
||||
{getPermissionLabel(currentAgent.permission?.webfetch)}
|
||||
{webfetchPermissionSummary.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { RiCheckLine, RiCloseLine, RiFileEditLine, RiGlobalLine, RiPencilAiLine, RiQuestionLine, RiTerminalBoxLine, RiTimeLine, RiToolsLine } from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Permission, PermissionResponse } from '@/types/permission';
|
||||
import type { PermissionRequest, PermissionResponse } from '@/types/permission';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
@@ -10,7 +10,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { DiffPreview, WritePreview } from './DiffPreview';
|
||||
|
||||
interface PermissionCardProps {
|
||||
permission: Permission;
|
||||
permission: PermissionRequest;
|
||||
onResponse?: (response: 'once' | 'always' | 'reject') => void;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const toolName = permission.type || 'Unknown Tool';
|
||||
const toolName = permission.permission || 'unknown';
|
||||
const tool = toolName.toLowerCase();
|
||||
|
||||
const getMeta = (key: string, fallback: string = ''): string => {
|
||||
@@ -106,9 +106,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
const description = getMeta('description');
|
||||
const workingDir = getMeta('cwd') || getMeta('working_directory') || getMeta('directory') || getMeta('path');
|
||||
const timeout = getMetaNum('timeout');
|
||||
|
||||
const commandInTitle = permission.title === command;
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
{description && (
|
||||
@@ -125,7 +123,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
{}
|
||||
{command && !commandInTitle && (
|
||||
{command && (
|
||||
<div>
|
||||
<SyntaxHighlighter
|
||||
language="bash"
|
||||
@@ -329,110 +327,15 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
|
||||
{}
|
||||
<div className="px-2 py-2">
|
||||
{/* Show patterns being requested */}
|
||||
{(permission.patterns as string[]) && (permission.patterns as string[]).length > 0 && (
|
||||
{permission.patterns.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="typography-meta text-muted-foreground mb-1">Patterns:</div>
|
||||
<code className="typography-meta px-2 py-1 bg-muted/30 rounded block break-all">
|
||||
{(permission.patterns as string[]).join(", ")}
|
||||
{permission.patterns.join(", ")}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!((permission.patterns as string[]) && (permission.patterns as string[]).length > 0) &&
|
||||
(permission.pattern as string | string[]) &&
|
||||
<div className="mb-2">
|
||||
<div className="typography-meta text-muted-foreground mb-1">Pattern:</div>
|
||||
<code className="typography-meta px-2 py-1 bg-muted/30 rounded block break-all">
|
||||
{Array.isArray(permission.pattern) ? permission.pattern.join(", ") : permission.pattern}
|
||||
</code>
|
||||
</div>
|
||||
}
|
||||
|
||||
{(() => {
|
||||
|
||||
let primaryContent = '';
|
||||
let primaryLanguage = 'text';
|
||||
let shouldHighlight = false;
|
||||
|
||||
if (tool === 'bash' || tool === 'shell' || tool === 'shell_command') {
|
||||
primaryContent = getMeta('command') || getMeta('cmd') || getMeta('script');
|
||||
primaryLanguage = 'bash';
|
||||
shouldHighlight = true;
|
||||
}
|
||||
|
||||
else if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool' || tool === 'write' || tool === 'create' || tool === 'file_write') {
|
||||
primaryContent = getMeta('path') || getMeta('file_path') || getMeta('filename') || getMeta('filePath');
|
||||
shouldHighlight = false;
|
||||
}
|
||||
|
||||
else if (tool === 'webfetch' || tool === 'fetch') {
|
||||
primaryContent = getMeta('url') || getMeta('uri') || getMeta('endpoint');
|
||||
shouldHighlight = false;
|
||||
}
|
||||
|
||||
const titleMatchesContent = permission.title === primaryContent;
|
||||
|
||||
if (titleMatchesContent && primaryContent && shouldHighlight) {
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<SyntaxHighlighter
|
||||
language={primaryLanguage}
|
||||
style={syntaxTheme}
|
||||
PreTag="div"
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '0.5rem',
|
||||
fontSize: 'var(--text-meta)',
|
||||
lineHeight: '1.25rem',
|
||||
background: 'rgb(var(--muted) / 0.3)',
|
||||
borderRadius: '0.25rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
overflow: 'visible'
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'break-word'
|
||||
}
|
||||
}}
|
||||
wrapLongLines={true}
|
||||
>
|
||||
{primaryContent}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (titleMatchesContent && primaryContent && !shouldHighlight) {
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<code className="typography-ui-label px-2 py-1 bg-muted/30 rounded block break-all">
|
||||
{primaryContent}
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (permission.title) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"typography-ui-label text-foreground mb-3",
|
||||
|
||||
(shouldHighlight || primaryContent) && "font-mono"
|
||||
)}>
|
||||
{permission.title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{}
|
||||
{renderToolContent()}
|
||||
</div>
|
||||
|
||||
@@ -460,7 +363,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
Allow Once
|
||||
</button>
|
||||
|
||||
{(permission.always as string[]) && (permission.always as string[]).length > 0 ? (
|
||||
{permission.always.length > 0 ? (
|
||||
<button
|
||||
onClick={() => handleResponse('always')}
|
||||
disabled={isResponding}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from 'react';
|
||||
import { RiCheckLine, RiCloseLine, RiTimeLine } from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Permission, PermissionResponse } from '@/types/permission';
|
||||
import type { PermissionRequest as PermissionRequestPayload, PermissionResponse } from '@/types/permission';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
|
||||
interface PermissionRequestProps {
|
||||
permission: Permission;
|
||||
permission: PermissionRequestPayload;
|
||||
onResponse?: (response: 'once' | 'always' | 'reject') => void;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
|
||||
|
||||
const command = typeof permission.metadata.command === 'string'
|
||||
? permission.metadata.command
|
||||
: permission.title;
|
||||
: (permission.patterns?.[0] ?? permission.permission);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -230,7 +230,6 @@ const getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => {
|
||||
};
|
||||
|
||||
const stripTaskMetadataFromOutput = (output: string): string => {
|
||||
// OpenCode appends a non-user-facing session marker for task tools.
|
||||
// Strip only a trailing <task_metadata>...</task_metadata> block.
|
||||
return output.replace(/\n*<task_metadata>[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd();
|
||||
};
|
||||
|
||||
@@ -33,6 +33,7 @@ export const VSCodeLayout: React.FC = () => {
|
||||
const messages = useSessionStore((state) => state.messages);
|
||||
const [hasInitializedOnce, setHasInitializedOnce] = React.useState<boolean>(() => configInitialized);
|
||||
const [isInitializing, setIsInitializing] = React.useState<boolean>(false);
|
||||
const lastBootstrapAttemptAt = React.useRef<number>(0);
|
||||
|
||||
// Navigate to chat when a session is selected
|
||||
React.useEffect(() => {
|
||||
@@ -54,6 +55,16 @@ export const VSCodeLayout: React.FC = () => {
|
||||
|
||||
// Listen for connection status changes
|
||||
React.useEffect(() => {
|
||||
// Catch up with the latest status even if the extension posted the connection message
|
||||
// before this component registered the event listener.
|
||||
const current =
|
||||
(typeof window !== 'undefined'
|
||||
? (window 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;
|
||||
@@ -88,12 +99,43 @@ export const VSCodeLayout: React.FC = () => {
|
||||
if (isInitializing || hasInitializedOnce || connectionStatus !== 'connected') {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (now - lastBootstrapAttemptAt.current < 750) {
|
||||
return;
|
||||
}
|
||||
lastBootstrapAttemptAt.current = now;
|
||||
setIsInitializing(true);
|
||||
try {
|
||||
const debugEnabled = (() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
return window.localStorage.getItem('openchamber_stream_debug') === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
if (debugEnabled) console.log('[OpenChamber][VSCode][bootstrap] attempt', { configInitialized });
|
||||
if (!configInitialized) {
|
||||
await initializeConfig();
|
||||
}
|
||||
const configState = useConfigStore.getState();
|
||||
// If OpenCode is still warming up, the initial provider/agent loads can fail and be swallowed by retries.
|
||||
// Only mark bootstrap complete when core datasets are present so we keep retrying on cold starts.
|
||||
if (!configState.isInitialized || !configState.isConnected || configState.providers.length === 0 || configState.agents.length === 0) {
|
||||
return;
|
||||
}
|
||||
await loadSessions();
|
||||
const sessionsError = useSessionStore.getState().error;
|
||||
if (debugEnabled) console.log('[OpenChamber][VSCode][bootstrap] post-load', {
|
||||
providers: configState.providers.length,
|
||||
agents: configState.agents.length,
|
||||
sessions: useSessionStore.getState().sessions.length,
|
||||
sessionsError,
|
||||
});
|
||||
if (typeof sessionsError === 'string' && sessionsError.length > 0) {
|
||||
return;
|
||||
}
|
||||
setHasInitializedOnce(true);
|
||||
} catch {
|
||||
// Ignore bootstrap failures
|
||||
@@ -262,5 +304,3 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
@@ -36,25 +35,51 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
}) => {
|
||||
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
|
||||
const loadAgents = useConfigStore((state) => state.loadAgents);
|
||||
const defaultAgentName = useConfigStore((state) => state.currentAgentName);
|
||||
const agents = getVisibleAgents();
|
||||
const selectableAgents = React.useMemo(
|
||||
() => agents.filter((agent) => agent.mode !== 'subagent'),
|
||||
[agents]
|
||||
);
|
||||
|
||||
// Load agents on mount
|
||||
React.useEffect(() => {
|
||||
loadAgents();
|
||||
}, [loadAgents]);
|
||||
|
||||
// Use empty string to represent "no agent" selection
|
||||
const handleValueChange = (newValue: string) => {
|
||||
onChange(newValue === '__none__' ? '' : newValue);
|
||||
};
|
||||
// Ensure we always have a valid selection (defaults to current default agent, then first selectable agent).
|
||||
React.useEffect(() => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert empty value to __none__ for the Select component (which doesn't handle empty strings well)
|
||||
const selectValue = value || '__none__';
|
||||
const trimmedValue = value.trim();
|
||||
if (trimmedValue.length > 0 && selectableAgents.some((agent) => agent.name === trimmedValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidateDefault =
|
||||
typeof defaultAgentName === 'string' && defaultAgentName.trim().length > 0
|
||||
? defaultAgentName.trim()
|
||||
: null;
|
||||
|
||||
if (candidateDefault && selectableAgents.some((agent) => agent.name === candidateDefault)) {
|
||||
onChange(candidateDefault);
|
||||
return;
|
||||
}
|
||||
|
||||
const firstAgent = selectableAgents[0]?.name;
|
||||
if (firstAgent) {
|
||||
onChange(firstAgent);
|
||||
}
|
||||
}, [defaultAgentName, disabled, onChange, selectableAgents, value]);
|
||||
|
||||
const selectValue = value.trim().length > 0 ? value : undefined;
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={selectValue}
|
||||
onValueChange={handleValueChange}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
@@ -62,20 +87,12 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
size="lg"
|
||||
className={className ?? 'max-w-full typography-meta text-foreground'}
|
||||
>
|
||||
<SelectValue placeholder="Select an agent (optional)" />
|
||||
<SelectValue placeholder="Select an agent" />
|
||||
</SelectTrigger>
|
||||
<SelectContent fitContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Default</SelectLabel>
|
||||
<SelectItem value="__none__" className="w-auto whitespace-nowrap">
|
||||
No agent (default)
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
|
||||
{agents.length > 0 && (
|
||||
{selectableAgents.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Agents</SelectLabel>
|
||||
{agents.map((agent) => (
|
||||
{selectableAgents.map((agent) => (
|
||||
<SelectItem
|
||||
key={agent.name}
|
||||
value={agent.name}
|
||||
|
||||
@@ -126,6 +126,9 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
|
||||
id,
|
||||
}) => {
|
||||
const { branches, isLoading, isGitRepository } = useBranchOptions(directory);
|
||||
const selectedLabel = React.useMemo(() => {
|
||||
return branches.find((option) => option.value === value)?.label ?? null;
|
||||
}, [branches, value]);
|
||||
|
||||
// Update value if it's no longer valid
|
||||
React.useEffect(() => {
|
||||
@@ -149,9 +152,11 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
|
||||
size="lg"
|
||||
className={className ?? 'max-w-full typography-meta text-foreground'}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={isLoading ? 'Loading branches…' : 'Select a branch'}
|
||||
/>
|
||||
{selectedLabel ? (
|
||||
<SelectValue>{selectedLabel}</SelectValue>
|
||||
) : (
|
||||
<SelectValue placeholder={isLoading ? 'Loading branches…' : 'Select a branch'} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent fitContent>
|
||||
<SelectGroup>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isIMECompositionEvent } from '@/lib/ime';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useModelLists } from '@/hooks/useModelLists';
|
||||
import type { ModelMetadata } from '@/types';
|
||||
@@ -325,6 +326,9 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (isIMECompositionEvent(e)) {
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -320,7 +320,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
id="multirun-agent"
|
||||
/>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Optional agent to use for all runs.
|
||||
Defaults to your configured default agent.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -126,7 +126,6 @@ export const ProvidersPage: React.FC = () => {
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
|
||||
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
|
||||
const loadProviders = useConfigStore((state) => state.loadProviders);
|
||||
const getModelMetadata = useConfigStore((state) => state.getModelMetadata);
|
||||
|
||||
const [authMethodsByProvider, setAuthMethodsByProvider] = React.useState<Record<string, AuthMethod[]>>({});
|
||||
@@ -275,8 +274,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
|
||||
toast.success('API key saved');
|
||||
setApiKeyInputs((prev) => ({ ...prev, [providerId]: '' }));
|
||||
await reloadOpenCodeConfiguration();
|
||||
await loadProviders();
|
||||
await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" });
|
||||
setSelectedProvider(providerId);
|
||||
} catch (error) {
|
||||
console.error('Failed to save API key:', error);
|
||||
@@ -375,8 +373,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
toast.success('OAuth connection completed');
|
||||
setOauthCodes((prev) => ({ ...prev, [codeKey]: '' }));
|
||||
setPendingOAuth(null);
|
||||
await reloadOpenCodeConfiguration();
|
||||
await loadProviders();
|
||||
await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" });
|
||||
setSelectedProvider(providerId);
|
||||
} catch (error) {
|
||||
console.error('Failed to complete OAuth flow:', error);
|
||||
@@ -423,8 +420,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
}
|
||||
|
||||
toast.success('Provider disconnected');
|
||||
await reloadOpenCodeConfiguration();
|
||||
await loadProviders();
|
||||
await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" });
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect provider:', error);
|
||||
toast.error('Failed to disconnect provider');
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DirectoryTree } from './DirectoryTree';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
|
||||
import { cn, formatPathForDisplay } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
@@ -33,7 +34,8 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
open,
|
||||
onOpenChange,
|
||||
}) => {
|
||||
const { currentDirectory, homeDirectory, setDirectory, isHomeReady } = useDirectoryStore();
|
||||
const { currentDirectory, homeDirectory, isHomeReady } = useDirectoryStore();
|
||||
const { addProject, getActiveProject } = useProjectsStore();
|
||||
const [pendingPath, setPendingPath] = React.useState<string | null>(null);
|
||||
const [pathInputValue, setPathInputValue] = React.useState('');
|
||||
const [hasUserSelection, setHasUserSelection] = React.useState(false);
|
||||
@@ -67,12 +69,13 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
if (open) {
|
||||
setHasUserSelection(false);
|
||||
setIsConfirming(false);
|
||||
// Initialize with current directory
|
||||
const initialPath = currentDirectory || homeDirectory || '';
|
||||
// Initialize with active project or current directory
|
||||
const activeProject = getActiveProject();
|
||||
const initialPath = activeProject?.path || currentDirectory || homeDirectory || '';
|
||||
setPendingPath(initialPath);
|
||||
setPathInputValue(formatPath(initialPath));
|
||||
}
|
||||
}, [open, currentDirectory, homeDirectory, formatPath]);
|
||||
}, [open, currentDirectory, homeDirectory, formatPath, getActiveProject]);
|
||||
|
||||
// Set initial pending path to home when ready (only if not yet selected)
|
||||
React.useEffect(() => {
|
||||
@@ -104,13 +107,10 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
if (!targetPath || isConfirming) {
|
||||
return;
|
||||
}
|
||||
if (targetPath === currentDirectory) {
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
setIsConfirming(true);
|
||||
try {
|
||||
let resolvedPath = targetPath;
|
||||
let projectId: string | undefined;
|
||||
|
||||
if (isDesktop) {
|
||||
const accessResult = await requestAccess(targetPath);
|
||||
@@ -121,6 +121,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
return;
|
||||
}
|
||||
resolvedPath = accessResult.path ?? targetPath;
|
||||
projectId = accessResult.projectId;
|
||||
|
||||
const startResult = await startAccessing(resolvedPath);
|
||||
if (!startResult.success) {
|
||||
@@ -131,7 +132,14 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
}
|
||||
}
|
||||
|
||||
setDirectory(resolvedPath);
|
||||
const added = addProject(resolvedPath, { id: projectId });
|
||||
if (!added) {
|
||||
toast.error('Failed to add project', {
|
||||
description: 'Please select a valid directory path.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
toast.error('Failed to select directory', {
|
||||
@@ -141,11 +149,10 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
setIsConfirming(false);
|
||||
}
|
||||
}, [
|
||||
currentDirectory,
|
||||
addProject,
|
||||
handleClose,
|
||||
isDesktop,
|
||||
requestAccess,
|
||||
setDirectory,
|
||||
startAccessing,
|
||||
isConfirming,
|
||||
]);
|
||||
@@ -200,9 +207,9 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
|
||||
const dialogHeader = (
|
||||
<DialogHeader className="flex-shrink-0 px-4 pb-2 pt-[calc(var(--oc-safe-area-top,0px)+0.5rem)] sm:px-0 sm:pb-3 sm:pt-0">
|
||||
<DialogTitle>Select project directory</DialogTitle>
|
||||
<DialogTitle>Add project directory</DialogTitle>
|
||||
<DialogDescription className="hidden sm:block">
|
||||
Choose the working directory for sessions and OpenCode operations.
|
||||
Choose a folder to add as a project.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
);
|
||||
@@ -304,7 +311,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
disabled={isConfirming || !hasUserSelection || (!pendingPath && !pathInputValue.trim())}
|
||||
className="flex-1 sm:flex-none sm:w-auto sm:min-w-[140px]"
|
||||
>
|
||||
{isConfirming ? 'Applying...' : 'Open Directory'}
|
||||
{isConfirming ? 'Adding...' : 'Add Project'}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
@@ -314,7 +321,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
<MobileOverlayPanel
|
||||
open={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
title="Select project directory"
|
||||
title="Add project directory"
|
||||
className="max-w-full"
|
||||
contentMaxHeightClassName="max-h-[min(70vh,520px)] h-[min(70vh,520px)]"
|
||||
footer={<div className="flex flex-row gap-2">{renderActionButtons()}</div>}
|
||||
|
||||
@@ -38,6 +38,9 @@ import {
|
||||
import { checkIsGitRepository, ensureOpenChamberIgnored, getGitBranches } from '@/lib/gitApi';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
|
||||
import { isDesktopRuntime } from '@/lib/desktop';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
@@ -122,6 +125,7 @@ export const SessionDialogs: React.FC = () => {
|
||||
const [isCheckingGitRepository, setIsCheckingGitRepository] = React.useState(false);
|
||||
const [isGitRepository, setIsGitRepository] = React.useState<boolean | null>(null);
|
||||
const [isCreatingWorktree, setIsCreatingWorktree] = React.useState(false);
|
||||
const [worktreeManagerProjectId, setWorktreeManagerProjectId] = React.useState<string | null>(null);
|
||||
const ensuredIgnoreDirectories = React.useRef<Set<string>>(new Set());
|
||||
const [deleteDialog, setDeleteDialog] = React.useState<DeleteDialogState | null>(null);
|
||||
const [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState<Array<{ session: Session; metadata: WorktreeMetadata }>>([]);
|
||||
@@ -140,13 +144,22 @@ export const SessionDialogs: React.FC = () => {
|
||||
getWorktreeMetadata,
|
||||
isLoading,
|
||||
} = useSessionStore();
|
||||
const { currentDirectory, homeDirectory, hasPersistedDirectory, isHomeReady } = useDirectoryStore();
|
||||
const { currentDirectory, homeDirectory, isHomeReady, setDirectory } = useDirectoryStore();
|
||||
const { projects, addProject, activeProjectId } = useProjectsStore();
|
||||
const { requestAccess, startAccessing } = useFileSystemAccess();
|
||||
const { agents } = useConfigStore();
|
||||
const { isSessionCreateDialogOpen, setSessionCreateDialogOpen } = useUIStore();
|
||||
const { isMobile, isTablet, hasTouchInput } = useDeviceInfo();
|
||||
const useMobileOverlay = isMobile || isTablet || hasTouchInput;
|
||||
|
||||
const projectDirectory = React.useMemo(() => normalizeProjectDirectory(currentDirectory), [currentDirectory]);
|
||||
const projectDirectory = React.useMemo(() => {
|
||||
const targetProjectId = worktreeManagerProjectId ?? activeProjectId;
|
||||
const targetProject = targetProjectId
|
||||
? projects.find((project) => project.id === targetProjectId) ?? null
|
||||
: null;
|
||||
const targetPath = targetProject?.path ?? currentDirectory;
|
||||
return normalizeProjectDirectory(targetPath);
|
||||
}, [activeProjectId, currentDirectory, projects, worktreeManagerProjectId]);
|
||||
const sanitizedNewBranchName = React.useMemo(() => sanitizeBranchNameInput(branchName), [branchName]);
|
||||
const worktreeTargetBranch = React.useMemo(
|
||||
() => (worktreeCreateMode === 'existing' ? existingWorktreeBranch.trim() : sanitizedNewBranchName),
|
||||
@@ -213,15 +226,75 @@ export const SessionDialogs: React.FC = () => {
|
||||
loadSessions();
|
||||
}, [loadSessions, currentDirectory]);
|
||||
|
||||
const projectsKey = React.useMemo(
|
||||
() => projects.map((project) => `${project.id}:${project.path}`).join('|'),
|
||||
[projects],
|
||||
);
|
||||
const lastProjectsKeyRef = React.useRef(projectsKey);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasShownInitialDirectoryPrompt && isHomeReady && !hasPersistedDirectory) {
|
||||
setIsDirectoryDialogOpen(true);
|
||||
setHasShownInitialDirectoryPrompt(true);
|
||||
if (projectsKey === lastProjectsKeyRef.current) {
|
||||
return;
|
||||
}
|
||||
}, [hasPersistedDirectory, hasShownInitialDirectoryPrompt, isHomeReady]);
|
||||
|
||||
lastProjectsKeyRef.current = projectsKey;
|
||||
loadSessions();
|
||||
}, [loadSessions, projectsKey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasShownInitialDirectoryPrompt || !isHomeReady || projects.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHasShownInitialDirectoryPrompt(true);
|
||||
|
||||
if (isDesktopRuntime()) {
|
||||
requestAccess('')
|
||||
.then(async (result) => {
|
||||
if (!result.success || !result.path) {
|
||||
if (result.error && result.error !== 'Directory selection cancelled') {
|
||||
toast.error('Failed to select directory', {
|
||||
description: result.error,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const accessResult = await startAccessing(result.path);
|
||||
if (!accessResult.success) {
|
||||
toast.error('Failed to open directory', {
|
||||
description: accessResult.error || 'Desktop could not grant file access.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const added = addProject(result.path, { id: result.projectId });
|
||||
if (!added) {
|
||||
toast.error('Failed to add project', {
|
||||
description: 'Please select a valid directory path.',
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Desktop: Error selecting directory:', error);
|
||||
toast.error('Failed to select directory');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDirectoryDialogOpen(true);
|
||||
}, [
|
||||
addProject,
|
||||
hasShownInitialDirectoryPrompt,
|
||||
isHomeReady,
|
||||
projects.length,
|
||||
requestAccess,
|
||||
startAccessing,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isSessionCreateDialogOpen) {
|
||||
setWorktreeManagerProjectId(null);
|
||||
setWorktreeCreateMode('new');
|
||||
setBranchName('');
|
||||
setExistingWorktreeBranch('');
|
||||
@@ -372,7 +445,9 @@ export const SessionDialogs: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
return sessionEvents.onCreateRequest(() => {
|
||||
return sessionEvents.onCreateRequest((request) => {
|
||||
const projectId = typeof request?.projectId === 'string' && request.projectId.trim() ? request.projectId : null;
|
||||
setWorktreeManagerProjectId(projectId);
|
||||
setWorktreeCreateMode('new');
|
||||
setBranchName('');
|
||||
setExistingWorktreeBranch('');
|
||||
@@ -555,6 +630,9 @@ export const SessionDialogs: React.FC = () => {
|
||||
setSessionDirectory(session.id, metadata.path);
|
||||
setWorktreeMetadata(session.id, createdMetadata);
|
||||
|
||||
// Ensure directory-scoped caches and session lists include the new worktree.
|
||||
setDirectory(metadata.path, { showOverlay: false });
|
||||
|
||||
await refreshWorktrees();
|
||||
setBranchName('');
|
||||
setExistingWorktreeBranch('');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,13 +34,13 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative flex flex-col min-h-0 w-full overflow-hidden", outerClassName)}
|
||||
className={cn("relative flex flex-col min-h-0 w-full overflow-hidden overscroll-none", outerClassName)}
|
||||
data-keyboard-avoid={keyboardAvoid ? "true" : undefined}
|
||||
>
|
||||
<Component
|
||||
ref={containerRef as React.Ref<HTMLElement>}
|
||||
className={cn(
|
||||
"overlay-scrollbar-target overlay-scrollbar-container",
|
||||
"overlay-scrollbar-target overlay-scrollbar-container overscroll-none",
|
||||
fillContainer ? "flex-1 min-h-0 w-full h-full" : "flex-none w-full h-auto",
|
||||
disableHorizontal ? "overflow-y-auto overflow-x-hidden" : "overflow-auto",
|
||||
className
|
||||
|
||||
@@ -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