feat: add multi-project support (#110)

* feat: Implement project management store with project path validation and synchronization

- Added `useProjectsStore` for managing projects, including adding, removing, renaming, and validating project paths.
- Implemented persistence for projects and active project ID using safe storage.
- Introduced synchronization from desktop settings to keep project data consistent.
- Enhanced session store to manage sessions by directory and added new methods for session management.
- Updated todo store to fetch session todos based on the directory context.
- Refactored server code to validate and resolve project directories for various API endpoints.
- Added project entry validation and sanitization to ensure data integrity.

* feat(settings): migrate legacy project settings and update settings loading logic

* feat: enhance project management with directory-aware settings and improved agent/command source handling

* feat: enhance session and project management with directory-aware settings and improved configuration refresh logic

* feat: enhance project management with worktree manager integration and project directory resolution

* feat: enhance agent groups store with project directory resolution and loading logic

* feat: add heartbeat management and global wrapping for SSE blocks in agent and chat providers

* feat: refactor command and project handling in useCommandsStore

- Replaced useDirectoryStore with useProjectsStore to manage project paths.
- Introduced getRequestDirectory function to determine the active project directory.
- Updated command fetching to respect project-level scoping.
- Enhanced error handling and logging for command configuration fetching.
- Improved command configuration saving and updating to utilize project directory context.

feat: enhance project path normalization in useProjectsStore

- Added resolveTildePath function to expand paths starting with ~.
- Updated normalizeProjectPath to utilize home directory for path expansion.

fix: update permission handling in useSessionStore

- Changed Permission type to PermissionRequest for clarity.
- Updated respondToPermission method to use requestId instead of permissionId.

refactor: improve permission utilities

- Introduced types for PermissionAction and PermissionRule.
- Enhanced getAgentDefinition and resolveConfigStore functions for better type safety.
- Added resolvePermissionAction to streamline permission resolution logic.

feat: add agent configuration retrieval endpoint

- Implemented new API endpoint to fetch agent configuration based on project directory.
- Enhanced getAgentPermissionSource to prioritize project-level permissions.

chore: update SDK version in package.json files

- Bumped @opencode-ai/sdk version to ^1.1.1 across all relevant package.json files.

refactor: streamline bridge message handling

- Updated handleBridgeMessage to accept directory parameter for agent and command requests.
- Improved local API request handling to extract directory from query parameters and headers.

feat: enhance project configuration management

- Added functions to retrieve and merge project configuration paths.
- Improved handling of existing project configuration files for agents and commands.

* feat: enhance VSCode integration and session management

- Added support for a sticky sidebar header background in light and dark themes.
- Introduced functions to read VSCode workspace directory and check if running in VSCode.
- Implemented detailed logging for session loading and creation processes.
- Enhanced session filtering based on directory structure and canonical paths.
- Added a new method to reorder projects and prevent modifications in VSCode workspace.
- Improved error handling and logging for app initialization and markdown file parsing.
- Updated API checks and health checks to ensure readiness before proceeding.
- Refactored code for better readability and maintainability across various modules.

* feat: improve agent and branch selection logic, enhance session management, and update multi-run creation response

* feat: add worktree management actions in agent group detail and sidebar, including delete and keep only options

* fix(ui): share IME guard and cover multi-run

* fix(session): reduce maximum visible sessions in group from 7 to 5
This commit is contained in:
Bohdan Triapitsyn
2026-01-06 21:31:04 +02:00
committed by GitHub
parent 8aa379e313
commit 18c5b4c7b5
84 changed files with 8399 additions and 2854 deletions
+84 -24
View File
@@ -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;
+127 -88
View File
@@ -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
+138 -20
View File
@@ -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');