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
+16 -3
View File
@@ -44,7 +44,7 @@ type AppProps = {
};
function App({ apis }: AppProps) {
const { initializeApp, isInitialized } = useConfigStore();
const { initializeApp, isInitialized, isConnected } = useConfigStore();
const { error, clearError, loadSessions } = useSessionStore();
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory);
@@ -121,11 +121,16 @@ function App({ apis }: AppProps) {
React.useEffect(() => {
const init = async () => {
// VS Code runtime bootstraps config + sessions after the managed OpenCode instance reports "connected".
// Doing the default initialization here can race with startup and lead to one-shot failures.
if (isVSCodeRuntime) {
return;
}
await initializeApp();
};
init();
}, [initializeApp]);
}, [initializeApp, isVSCodeRuntime]);
React.useEffect(() => {
if (isSwitchingDirectory) {
@@ -133,13 +138,21 @@ function App({ apis }: AppProps) {
}
const syncDirectoryAndSessions = async () => {
// VS Code runtime loads sessions via VSCodeLayout bootstrap to avoid startup races.
if (isVSCodeRuntime) {
return;
}
if (!isConnected) {
return;
}
opencodeClient.setDirectory(currentDirectory);
await loadSessions();
};
syncDirectoryAndSessions();
}, [currentDirectory, isSwitchingDirectory, loadSessions]);
}, [currentDirectory, isSwitchingDirectory, loadSessions, isConnected, isVSCodeRuntime]);
useEventStream();
+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');
+202 -310
View File
@@ -1,11 +1,12 @@
import React from 'react';
import { opencodeClient } from '@/lib/opencode/client';
import { opencodeClient, type RoutedOpencodeEvent } from '@/lib/opencode/client';
import { saveSessionCursor } from '@/lib/messageCursorPersistence';
import { useSessionStore } from '@/stores/useSessionStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore, type EventStreamStatus } from '@/stores/useUIStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import type { Part, Session, Message, Permission } from '@opencode-ai/sdk/v2';
import type { Part, Session, Message } from '@opencode-ai/sdk/v2';
import type { PermissionRequest } from '@/types/permission';
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
import { handleTodoUpdatedEvent } from '@/stores/useTodoStore';
@@ -93,7 +94,9 @@ export const useEventStream = () => {
sessions,
getWorktreeMetadata,
loadMessages,
loadSessions
loadSessions,
updateSession,
removeSessionFromStore
} = useSessionStore();
const { checkConnection } = useConfigStore();
@@ -127,6 +130,31 @@ export const useEventStream = () => {
return undefined;
}, [activeSessionDirectory, fallbackDirectory]);
React.useEffect(() => {
let cancelled = false;
const bootstrapPendingPermissions = async () => {
try {
const pending = await opencodeClient.listPendingPermissions();
if (cancelled || pending.length === 0) {
return;
}
pending.forEach((request) => {
addPermission(request as unknown as PermissionRequest);
});
} catch {
// ignored
}
};
void bootstrapPendingPermissions();
return () => {
cancelled = true;
};
}, [addPermission]);
const normalizeDirectory = React.useCallback((value: string | null | undefined): string | null => {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
@@ -250,6 +278,7 @@ export const useEventStream = () => {
const isCleaningUpRef = React.useRef(false);
const resyncInFlightRef = React.useRef<Promise<void> | null>(null);
const lastResyncAtRef = React.useRef(0);
const permissionToastShownRef = React.useRef<Set<string>>(new Set());
const resolveVisibilityState = React.useCallback((): 'visible' | 'hidden' => {
if (typeof document === 'undefined') return 'visible';
@@ -265,7 +294,6 @@ export const useEventStream = () => {
const staleCheckIntervalRef = React.useRef<NodeJS.Timeout | null>(null);
const lastEventTimestampRef = React.useRef<number>(Date.now());
const isDesktopRuntimeRef = React.useRef<boolean>(false);
const activityStreamAbortControllerRef = React.useRef<AbortController | null>(null);
const maybeBootstrapIfStale = React.useCallback(
(reason: string) => {
@@ -299,7 +327,7 @@ export const useEventStream = () => {
}, [currentSessionId]);
const requestSessionMetadataRefresh = React.useCallback(
(sessionId: string | undefined | null) => {
(sessionId: string | undefined | null, directoryOverride?: string | null) => {
if (!sessionId) return;
const now = Date.now();
@@ -310,9 +338,35 @@ export const useEventStream = () => {
timestamps.set(sessionId, now);
const resolveDirectoryForSession = (id: string): string | null => {
if (typeof directoryOverride === 'string' && directoryOverride.trim().length > 0) {
return directoryOverride.trim();
}
try {
const metadata = getWorktreeMetadata?.(id);
if (metadata?.path) {
return metadata.path;
}
} catch {
// ignored
}
const sessionRecord = sessions.find((entry) => entry.id === id) as Session & { directory?: string | null };
if (sessionRecord && typeof sessionRecord.directory === 'string' && sessionRecord.directory.trim().length > 0) {
return sessionRecord.directory.trim();
}
return null;
};
setTimeout(async () => {
try {
const session = await opencodeClient.getSession(sessionId);
const directory = resolveDirectoryForSession(sessionId);
const session = directory
? await opencodeClient.withDirectory(directory, () => opencodeClient.getSession(sessionId))
: await opencodeClient.getSession(sessionId);
if (session) {
const patch: Partial<Session> = {};
if (typeof session.title === 'string' && session.title.length > 0) {
@@ -330,21 +384,9 @@ export const useEventStream = () => {
}
}, 100);
},
[applySessionMetadata]
[applySessionMetadata, getWorktreeMetadata, sessions]
);
const requestSessionListRefresh = React.useCallback(() => {
if (sessionRefreshTimeoutRef.current) return;
sessionRefreshTimeoutRef.current = setTimeout(() => {
sessionRefreshTimeoutRef.current = null;
try {
void loadSessions();
} catch (error) {
console.warn('Failed to refresh sessions after stream completion:', error);
}
}, 500);
}, [loadSessions]);
const updateSessionActivityPhase = React.useCallback((sessionId: string, phase: 'idle' | 'busy' | 'cooldown') => {
const storePhase = useSessionStore.getState().sessionActivityPhase?.get(sessionId);
@@ -390,12 +432,28 @@ export const useEventStream = () => {
sessionStatusLastRefreshAtRef.current = now;
const applyStatusMap = (statusMap: Record<string, { type?: string }>) => {
const observed = new Set<string>();
const knownSessionIds = new Set(sessions.map((session) => session.id));
Object.entries(statusMap).forEach(([sessionId, raw]) => {
if (!sessionId || !raw) return;
observed.add(sessionId);
const phase: 'idle' | 'busy' =
raw.type === 'busy' || raw.type === 'retry' ? 'busy' : 'idle';
updateSessionActivityPhase(sessionId, phase);
});
// OpenCode's /session/status may omit idle sessions (returns only busy/retry).
// Treat missing entries as idle to avoid sessions getting stuck "working".
const currentPhases = useSessionStore.getState().sessionActivityPhase;
if (!currentPhases) return;
for (const [sessionId, phase] of currentPhases.entries()) {
if (!knownSessionIds.has(sessionId)) continue;
if ((phase === 'busy' || phase === 'cooldown') && !observed.has(sessionId)) {
updateSessionActivityPhase(sessionId, 'idle');
}
}
};
const task = (async (): Promise<void> => {
@@ -435,6 +493,19 @@ export const useEventStream = () => {
Object.assign(merged, result.value);
});
if (Object.keys(merged).length === 0) {
const hasActivePhases = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some(
(phase) => phase === 'busy' || phase === 'cooldown'
);
if (hasActivePhases) {
const healthy = await opencodeClient.checkHealth().catch(() => false);
if (!healthy) {
return;
}
}
}
applyStatusMap(merged);
} catch {
// ignored
@@ -463,92 +534,6 @@ export const useEventStream = () => {
previousSessionDirectoryRef.current = nextDirectory;
}, [currentSessionId, refreshSessionActivityStatus, resolveSessionDirectoryForStatus]);
const handleActivityEvent = React.useCallback((event: EventData) => {
if (!event?.type) return;
const props = (event.properties ?? {}) as Record<string, unknown>;
if (event.type === 'openchamber:session-activity') {
const sessionId =
typeof props.sessionId === 'string'
? props.sessionId
: typeof props.sessionID === 'string'
? props.sessionID
: null;
const phase = typeof props.phase === 'string' ? props.phase : null;
if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) {
updateSessionActivityPhase(sessionId, phase);
requestSessionListRefresh();
}
return;
}
if (event.type === 'session.status') {
const sessionId =
typeof props.sessionID === 'string'
? props.sessionID
: typeof props.sessionId === 'string'
? props.sessionId
: null;
const statusObj =
typeof props.status === 'object' && props.status !== null
? (props.status as Record<string, unknown>)
: null;
const statusType = typeof statusObj?.type === 'string' ? (statusObj.type as string) : null;
if (sessionId && statusType) {
updateSessionActivityPhase(
sessionId,
statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle',
);
requestSessionListRefresh();
}
return;
}
if (event.type === 'session.idle') {
const sessionId =
typeof props.sessionID === 'string'
? props.sessionID
: typeof props.sessionId === 'string'
? props.sessionId
: null;
if (sessionId) {
updateSessionActivityPhase(sessionId, 'idle');
requestSessionListRefresh();
}
return;
}
if (event.type === 'message.updated' || event.type === 'message.part.updated') {
const messageInfo =
typeof props.info === 'object' && props.info !== null ? (props.info as Record<string, unknown>) : props;
const sessionId =
typeof (messageInfo as { sessionID?: unknown }).sessionID === 'string'
? (messageInfo as { sessionID?: string }).sessionID
: typeof (messageInfo as { sessionId?: unknown }).sessionId === 'string'
? (messageInfo as { sessionId?: string }).sessionId
: typeof props.sessionID === 'string'
? (props.sessionID as string)
: typeof props.sessionId === 'string'
? (props.sessionId as string)
: null;
const role = (messageInfo as { role?: unknown }).role;
const finish = (messageInfo as { finish?: unknown }).finish;
if (sessionId && role === 'assistant' && finish === 'stop') {
const currentPhase = useSessionStore.getState().sessionActivityPhase?.get(sessionId);
if (currentPhase === 'busy') {
updateSessionActivityPhase(sessionId, 'cooldown');
requestSessionListRefresh();
}
}
return;
}
}, [requestSessionListRefresh, updateSessionActivityPhase]);
const handleEvent = React.useCallback((event: EventData) => {
lastEventTimestampRef.current = Date.now();
@@ -603,8 +588,7 @@ export const useEventStream = () => {
const phase = typeof props.phase === 'string' ? props.phase : null;
if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) {
updateSessionActivityPhase(sessionId, phase);
// Refresh session list on activity changes (same trigger as activity indication)
requestSessionListRefresh();
requestSessionMetadataRefresh(sessionId, typeof props.directory === 'string' ? props.directory : null);
}
break;
}
@@ -621,8 +605,7 @@ export const useEventStream = () => {
sessionId,
statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle',
);
// Refresh session list on status changes (same trigger as activity indication)
requestSessionListRefresh();
requestSessionMetadataRefresh(sessionId, typeof props.directory === 'string' ? props.directory : null);
}
}
break;
@@ -1009,11 +992,15 @@ export const useEventStream = () => {
}
const rawMessageSessionId = (message as { sessionID?: string }).sessionID;
const messageSessionId: string =
typeof rawMessageSessionId === 'string' && rawMessageSessionId.length > 0
const messageSessionId: string =
typeof rawMessageSessionId === 'string' && rawMessageSessionId.length > 0
? rawMessageSessionId
: sessionId;
requestSessionMetadataRefresh(messageSessionId);
requestSessionMetadataRefresh(
messageSessionId,
typeof props.directory === 'string' ? props.directory : null,
);
const summaryInfo = message as Message & { summary?: boolean };
if (summaryInfo.summary && typeof messageSessionId === 'string') {
@@ -1023,6 +1010,7 @@ export const useEventStream = () => {
break;
}
case 'session.created':
case 'session.updated': {
const candidate = (typeof props.info === 'object' && props.info !== null) ? props.info as Record<string, unknown> :
(typeof props.sessionInfo === 'object' && props.sessionInfo !== null) ? props.sessionInfo as Record<string, unknown> :
@@ -1038,6 +1026,32 @@ export const useEventStream = () => {
(typeof props.time === 'object' && props.time !== null) ? props.time as Record<string, unknown> : null;
const compactingTimestamp = timeSource && typeof timeSource.compacting === 'number' ? timeSource.compacting as number : null;
updateSessionCompaction(sessionId, compactingTimestamp);
const sessionDirectory = typeof (candidate as { directory?: unknown }).directory === 'string'
? (candidate as { directory: string }).directory
: typeof props.directory === 'string'
? (props.directory as string)
: null;
const patchedSession = {
...(candidate as unknown as Record<string, unknown>),
id: sessionId,
...(sessionDirectory ? { directory: sessionDirectory } : {}),
} as unknown as Session;
updateSession(patchedSession);
}
break;
}
case 'session.deleted': {
const sessionId = typeof props.sessionID === 'string'
? props.sessionID
: typeof props.id === 'string'
? props.id
: null;
if (sessionId) {
removeSessionFromStore(sessionId);
}
break;
}
@@ -1058,56 +1072,65 @@ export const useEventStream = () => {
break;
}
case 'permission.updated':
if (currentSessionId === props.sessionID) {
addPermission(props as unknown as Permission);
case 'permission.asked': {
if (!('sessionID' in props) || typeof props.sessionID !== 'string') {
break;
}
break;
case 'permission.asked':
// New permission system from OpenCode's PermissionNext
if ('sessionID' in props && props.sessionID === currentSessionId) {
const askedProps = props as {
id: string;
permission: string;
sessionID: string;
patterns?: string[];
always?: string[];
metadata: Record<string, unknown>;
tool?: {
messageID: string;
callID: string;
};
};
const request = props as unknown as PermissionRequest;
// Convert new permission.asked event format to Permission type
const permission = {
id: askedProps.id,
type: askedProps.permission,
pattern: askedProps.patterns, // Map patterns to pattern field for compatibility
sessionID: askedProps.sessionID,
messageID: askedProps.tool?.messageID || askedProps.sessionID,
callID: askedProps.tool?.callID,
title: `${askedProps.permission} permission required`,
metadata: {
...askedProps.metadata,
always: askedProps.always, // Store always in metadata for UI access
patterns: askedProps.patterns,
},
time: { created: Date.now() },
} as unknown as Permission;
addPermission(permission);
addPermission(request);
// Notify if permission is for another session (common with child sessions).
const toastKey = `${request.sessionID}:${request.id}`;
if (!permissionToastShownRef.current.has(toastKey)) {
setTimeout(() => {
const current = currentSessionIdRef.current;
if (current === request.sessionID) {
return;
}
const pending = useSessionStore
.getState()
.permissions
.get(request.sessionID)
?.some((entry) => entry.id === request.id);
if (!pending) {
return;
}
permissionToastShownRef.current.add(toastKey);
const sessionTitle =
useSessionStore.getState().sessions.find((s) => s.id === request.sessionID)?.title ||
'Session';
import('sonner').then(({ toast }) => {
toast.warning('Permission required', {
description: sessionTitle,
action: {
label: 'Open',
onClick: () => {
useUIStore.getState().setActiveMainTab('chat');
void useSessionStore.getState().setCurrentSession(request.sessionID);
},
},
});
});
}, 0);
}
break;
}
case 'permission.replied':
// Permission was responded to - UI will update via permissionStore
break;
case 'todo.updated': {
const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null;
const todos = Array.isArray(props.todos) ? props.todos : [];
if (sessionId && todos.length > 0) {
const todos = Array.isArray(props.todos) ? props.todos : null;
if (sessionId && todos) {
handleTodoUpdatedEvent(
sessionId,
todos as Array<{ id: string; content: string; status: string; priority: string }>
@@ -1124,12 +1147,13 @@ export const useEventStream = () => {
addPermission,
checkConnection,
requestSessionMetadataRefresh,
requestSessionListRefresh,
updateSessionCompaction,
applySessionMetadata,
trackMessage,
reportMessage,
updateSessionActivityPhase,
updateSession,
removeSessionFromStore,
bootstrapState
]);
@@ -1144,7 +1168,6 @@ export const useEventStream = () => {
console.debug('[useEventStream] Connection state:', {
isDesktopRuntime: isDesktopRuntimeRef.current,
hasUnsubscribe: Boolean(unsubscribeRef.current),
hasActivityStream: Boolean(activityStreamAbortControllerRef.current),
currentSessionId: currentSessionIdRef.current,
effectiveDirectory,
onlineStatus: onlineStatusRef.current,
@@ -1183,14 +1206,6 @@ export const useEventStream = () => {
}
}
if (activityStreamAbortControllerRef.current) {
try {
activityStreamAbortControllerRef.current.abort();
} catch (error) {
console.warn('[useEventStream] Error during activity stream abort:', error);
}
activityStreamAbortControllerRef.current = null;
}
isCleaningUpRef.current = false;
}, []);
@@ -1239,9 +1254,9 @@ export const useEventStream = () => {
lastEventTimestampRef.current = Date.now();
publishStatus('connected', null);
checkConnection();
const hasBusySessions = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some(
(phase) => phase === 'busy'
(phase) => phase === 'busy' || phase === 'cooldown'
);
if (hasBusySessions) {
void refreshSessionActivityStatus();
@@ -1278,143 +1293,29 @@ export const useEventStream = () => {
}
try {
const sdkUnsub = opencodeClient.subscribeToEvents(
handleEvent,
const sdkUnsub = opencodeClient.subscribeToGlobalEvents(
(event: RoutedOpencodeEvent) => {
const payload = event.payload as unknown as EventData;
const payloadRecord = event.payload as unknown as Record<string, unknown>;
const baseProperties =
typeof payloadRecord.properties === 'object' && payloadRecord.properties !== null
? (payloadRecord.properties as Record<string, unknown>)
: {};
const properties =
event.directory && event.directory !== 'global'
? { ...baseProperties, directory: event.directory }
: baseProperties;
handleEvent({
type: typeof (payload as { type?: unknown }).type === 'string' ? (payload as { type: string }).type : '',
properties,
});
},
onError,
onOpen,
effectiveDirectory,
{ scope: 'directory', key: 'events' }
);
if (!isDesktopRuntimeRef.current) {
if (activityStreamAbortControllerRef.current) {
activityStreamAbortControllerRef.current.abort();
}
const activityAbortController = new AbortController();
activityStreamAbortControllerRef.current = activityAbortController;
const parseSseEventBlock = (block: string): EventData | null => {
if (!block) return null;
const dataLines = block
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).replace(/^\s/, ''));
if (dataLines.length === 0) {
return null;
}
const payloadText = dataLines.join('\n').trim();
if (!payloadText) {
return null;
}
try {
const parsed = JSON.parse(payloadText) as unknown;
if (!parsed || typeof parsed !== 'object') {
return null;
}
const record = parsed as Record<string, unknown>;
if (typeof record.type === 'string') {
return record as unknown as EventData;
}
const nestedPayload = record.payload;
if (nestedPayload && typeof nestedPayload === 'object') {
const nestedRecord = nestedPayload as Record<string, unknown>;
if (typeof nestedRecord.type === 'string') {
return nestedRecord as unknown as EventData;
}
}
return null;
} catch {
return null;
}
};
void (async () => {
try {
const candidateEndpoints = ['/api/global/event', '/api/event'];
let response: Response | null = null;
let lastError: unknown = null;
for (const endpoint of candidateEndpoints) {
try {
const candidateResponse = await fetch(endpoint, {
method: 'GET',
headers: {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
},
signal: activityAbortController.signal,
});
if (candidateResponse.ok && candidateResponse.body) {
response = candidateResponse;
if (streamDebugEnabled()) {
console.info('[useEventStream] Activity stream connected:', endpoint);
}
break;
}
lastError = new Error(`Activity stream failed: ${candidateResponse.status}`);
} catch (error) {
lastError = error;
}
}
if (!response) {
throw lastError ?? new Error('Activity stream failed');
}
const responseBody = response.body;
if (!responseBody) {
throw new Error('Activity stream missing body');
}
const reader = responseBody.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (activityAbortController.signal.aborted) break;
if (!value || value.length === 0) continue;
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
const blocks = buffer.split('\n\n');
buffer = blocks.pop() ?? '';
for (const block of blocks) {
const event = parseSseEventBlock(block);
if (event) {
handleActivityEvent(event);
}
}
}
const remaining = buffer.trim();
if (remaining) {
const event = parseSseEventBlock(remaining);
if (event) {
handleActivityEvent(event);
}
}
} catch (error) {
if (!activityAbortController.signal.aborted) {
console.warn('[useEventStream] Activity stream error:', error);
}
} finally {
if (activityStreamAbortControllerRef.current === activityAbortController) {
activityStreamAbortControllerRef.current = null;
}
}
})();
}
const compositeUnsub = () => {
try {
@@ -1428,10 +1329,6 @@ export const useEventStream = () => {
unsubscribeRef.current = compositeUnsub;
} else {
compositeUnsub();
if (activityStreamAbortControllerRef.current) {
activityStreamAbortControllerRef.current.abort();
activityStreamAbortControllerRef.current = null;
}
}
} catch (subscriptionError) {
console.error('[useEventStream] Error during subscription:', subscriptionError);
@@ -1445,7 +1342,6 @@ export const useEventStream = () => {
resyncMessages,
requestSessionMetadataRefresh,
handleEvent,
handleActivityEvent,
effectiveDirectory,
refreshSessionActivityStatus,
waitForDesktopBridge,
@@ -1499,8 +1395,7 @@ export const useEventStream = () => {
const phase = typeof event.detail?.phase === 'string' ? event.detail.phase : null;
if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) {
updateSessionActivityPhase(sessionId, phase);
// Refresh session list on activity changes (same trigger as activity indication)
requestSessionListRefresh();
requestSessionMetadataRefresh(sessionId);
}
};
window.addEventListener('openchamber:session-activity', desktopActivityHandler as EventListener);
@@ -1543,10 +1438,9 @@ export const useEventStream = () => {
resyncMessages(sessionId, 'visibility_restore').catch(() => {});
requestSessionMetadataRefresh(sessionId);
}
void loadSessions();
void refreshSessionActivityStatus();
publishStatus('connecting', 'Resuming stream');
void refreshSessionActivityStatus();
publishStatus('connecting', 'Resuming stream');
startStream({ resetAttempts: true });
}
} else {
@@ -1571,7 +1465,6 @@ export const useEventStream = () => {
.then(() => console.info('[useEventStream] Messages refreshed on focus'))
.catch((err) => console.warn('[useEventStream] Failed to refresh messages:', err));
}
void loadSessions();
void refreshSessionActivityStatus();
publishStatus('connecting', 'Resuming stream');
@@ -1619,7 +1512,7 @@ export const useEventStream = () => {
const now = Date.now();
const hasBusySessions = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some(
(phase) => phase === 'busy'
(phase) => phase === 'busy' || phase === 'cooldown'
);
if (hasBusySessions) {
void refreshSessionActivityStatus();
@@ -1692,7 +1585,6 @@ export const useEventStream = () => {
scheduleReconnect,
loadMessages,
requestSessionMetadataRefresh,
requestSessionListRefresh,
updateSessionActivityPhase,
refreshSessionActivityStatus,
shouldHoldConnection,
+1 -1
View File
@@ -8,7 +8,7 @@ export const useFileSystemAccess = () => {
setIsDesktop(isDesktopRuntime());
}, []);
const requestAccess = useCallback(async (directoryPath: string): Promise<{ success: boolean; path?: string; error?: string }> => {
const requestAccess = useCallback(async (directoryPath: string): Promise<{ success: boolean; path?: string; projectId?: string; error?: string }> => {
if (!isDesktop) {
return { success: true, path: directoryPath };
}
+29 -12
View File
@@ -2,11 +2,12 @@ import React from 'react';
import { toast } from 'sonner';
import { useSessionStore } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { sessionEvents } from '@/lib/sessionEvents';
import { isDesktopRuntime } from '@/lib/desktop';
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
const MENU_ACTION_EVENT = 'openchamber:menu-action';
@@ -42,20 +43,36 @@ export const useMenuActions = (
setSettingsDialogOpen,
setAboutDialogOpen,
} = useUIStore();
const { setDirectory } = useDirectoryStore();
const { addProject } = useProjectsStore();
const { requestAccess, startAccessing } = useFileSystemAccess();
const { setThemeMode } = useThemeSystem();
const isDownloadingLogsRef = React.useRef(false);
const handleChangeWorkspace = React.useCallback(() => {
if (isDesktopRuntime() && window.opencodeDesktop?.requestDirectoryAccess) {
window.opencodeDesktop
.requestDirectoryAccess('')
.then((result) => {
if (result.success && result.path) {
setDirectory(result.path, { showOverlay: true });
} else if (result.error && result.error !== 'Directory selection cancelled') {
toast.error('Failed to select directory', {
description: result.error,
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.',
});
}
})
@@ -66,7 +83,7 @@ export const useMenuActions = (
} else {
sessionEvents.requestDirectoryDialog();
}
}, [setDirectory]);
}, [addProject, requestAccess, startAccessing]);
React.useEffect(() => {
const handleMenuAction = (event: Event) => {
+2
View File
@@ -407,6 +407,7 @@ svg.animate-spin {
--sidebar-accent-foreground: oklch(0.25 0.02 40); /* Dark warm text */
--sidebar-border: oklch(0.85 0.02 70); /* Warm border */
--sidebar-ring: oklch(0.65 0.2 55); /* Focus ring */
--sidebar-stuck-bg: #DDDDD3; /* Desktop sidebar sticky header background */
}
.dark {
@@ -443,6 +444,7 @@ svg.animate-spin {
--sidebar-accent-foreground: oklch(0.85 0.02 90); /* #cdccc3 */
--sidebar-border: oklch(0.31 0.01 35); /* #393836 */
--sidebar-ring: oklch(0.77 0.17 85); /* #edb449 */
--sidebar-stuck-bg: #1F1F1D; /* Desktop sidebar sticky header background */
}
* {
+10
View File
@@ -322,6 +322,14 @@ export interface FilesAPI {
createDirectory(path: string): Promise<{ success: boolean; path: string }>;
}
export interface ProjectEntry {
id: string;
path: string;
label?: string;
addedAt?: number;
lastOpenedAt?: number;
}
export interface SettingsPayload {
themeId?: string;
useSystemTheme?: boolean;
@@ -330,6 +338,8 @@ export interface SettingsPayload {
darkThemeId?: string;
lastDirectory?: string;
homeDirectory?: string;
projects?: ProjectEntry[];
activeProjectId?: string;
approvedDirectories?: string[];
securityScopedBookmarks?: string[];
pinnedDirectories?: string[];
+6 -2
View File
@@ -1,3 +1,5 @@
import type { ProjectEntry } from '@/lib/api/types';
export type AssistantNotificationPayload = {
title?: string;
body?: string;
@@ -43,6 +45,8 @@ export type DesktopSettings = {
darkThemeId?: string;
lastDirectory?: string;
homeDirectory?: string;
projects?: ProjectEntry[];
activeProjectId?: string;
approvedDirectories?: string[];
securityScopedBookmarks?: string[];
pinnedDirectories?: string[];
@@ -72,7 +76,7 @@ export type DesktopApi = {
getHomeDirectory?: () => Promise<{ success: boolean; path: string | null }>;
getSettings?: () => Promise<DesktopSettings>;
updateSettings?: (changes: Partial<DesktopSettings>) => Promise<DesktopSettings>;
requestDirectoryAccess?: (path: string) => Promise<{ success: boolean; path?: string; error?: string }>;
requestDirectoryAccess?: (path: string) => Promise<{ success: boolean; path?: string; projectId?: string; error?: string }>;
startAccessingDirectory?: (path: string) => Promise<{ success: boolean; error?: string }>;
stopAccessingDirectory?: (path: string) => Promise<{ success: boolean; error?: string }>;
notifyAssistantCompletion?: (payload?: AssistantNotificationPayload) => Promise<{ success: boolean }>;
@@ -205,7 +209,7 @@ export const updateDesktopSettings = async (
export const requestDirectoryAccess = async (
directoryPath: string
): Promise<{ success: boolean; path?: string; error?: string }> => {
): Promise<{ success: boolean; path?: string; projectId?: string; error?: string }> => {
const api = getDesktopApi();
if (!api || !api.requestDirectoryAccess) {
return { success: true, path: directoryPath };
+14
View File
@@ -0,0 +1,14 @@
import type React from 'react';
/**
* Detects if a keyboard event is part of IME composition.
* Uses both `isComposing` and the `keyCode === 229` fallback.
*
* Note: `keyCode` is deprecated, but `229` remains a practical fallback for
* some WebKit-based environments (including Tauri WebView) where composition
* events can be ordered unexpectedly.
*/
export const isIMECompositionEvent = (e: React.KeyboardEvent): boolean => {
return e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229;
};
+452 -25
View File
@@ -13,6 +13,7 @@ import type {
FilePartInput,
Event,
} from "@opencode-ai/sdk/v2";
import type { PermissionRequest } from "@/types/permission";
type StreamEvent<TData> = {
data: TData;
event?: string;
@@ -20,6 +21,11 @@ type StreamEvent<TData> = {
retry?: number;
};
export type RoutedOpencodeEvent = {
directory: string;
payload: Event;
};
// Use relative path by default (works with both dev and nginx proxy server)
// Can be overridden with VITE_OPENCODE_URL for absolute URLs in special deployments
const DEFAULT_BASE_URL = import.meta.env.VITE_OPENCODE_URL || "/api";
@@ -132,6 +138,14 @@ class OpencodeService {
private sseAbortControllers: Map<string, AbortController> = new Map();
private currentDirectory: string | undefined = undefined;
private globalSseAbortController: AbortController | null = null;
private globalSseTask: Promise<void> | null = null;
private globalSseLastEventId: string | undefined;
private globalSseIsConnected = false;
private globalSseListeners: Set<(event: RoutedOpencodeEvent) => void> = new Set();
private globalSseOpenListeners: Set<() => void> = new Set();
private globalSseErrorListeners: Set<(error: unknown) => void> = new Set();
constructor(baseUrl: string = DEFAULT_BASE_URL) {
const desktopBase = resolveDesktopBaseUrl();
const requestedBaseUrl = desktopBase || baseUrl;
@@ -726,21 +740,46 @@ class OpencodeService {
return this.getSessionStatusForDirectory(null);
}
// Tools
async listToolIds(options?: { directory?: string | null }): Promise<string[]> {
try {
const directory = typeof options?.directory === 'string'
? options.directory.trim()
: (this.currentDirectory ? this.currentDirectory.trim() : '');
const result = await this.client.tool.ids(directory ? { directory } : undefined);
const tools = (result.data || []) as unknown as string[];
return tools.filter((tool) => typeof tool === 'string' && tool !== 'invalid');
} catch {
return [];
}
}
// Permissions
async respondToPermission(
sessionId: string,
permissionId: string,
response: 'once' | 'always' | 'reject'
async replyToPermission(
requestId: string,
reply: 'once' | 'always' | 'reject',
options?: { message?: string }
): Promise<boolean> {
const result = await this.client.permission.respond({
sessionID: sessionId,
permissionID: permissionId,
const result = await this.client.permission.reply({
requestID: requestId,
...(this.currentDirectory ? { directory: this.currentDirectory } : {}),
response
reply,
...(options?.message ? { message: options.message } : {}),
});
return result.data || false;
}
async listPendingPermissions(): Promise<PermissionRequest[]> {
try {
// Permission requests are global across sessions; do not scope by directory.
const result = await this.client.permission.list();
return (result.data || []) as unknown as PermissionRequest[];
} catch {
return [];
}
}
// Configuration
async getConfig(): Promise<Config> {
const response = await this.client.config.get();
@@ -773,7 +812,7 @@ class OpencodeService {
/**
* Update config with a partial modification function.
* This handles the GET-modify-PATCH pattern required by OpenCode API.
* This handles the GET-modify-PATCH pattern required by the upstream API.
*
* NOTE: This method is deprecated for agent configuration.
* Use backend endpoints at /api/config/agents/* instead, which write directly to files.
@@ -830,6 +869,286 @@ class OpencodeService {
}
}
private parseSseBlock(block: string): { data: unknown; id?: string } | null {
if (!block) return null;
const lines = block.split('\n');
const dataLines: string[] = [];
let eventId: string | undefined;
for (const line of lines) {
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).replace(/^\s/, ''));
} else if (line.startsWith('id:')) {
const candidate = line.slice(3).trim();
if (candidate) {
eventId = candidate;
}
}
}
if (dataLines.length === 0) {
return null;
}
const payloadText = dataLines.join('\n').trim();
if (!payloadText) {
return null;
}
try {
const data = JSON.parse(payloadText) as unknown;
return { data, id: eventId };
} catch {
return null;
}
}
private normalizeRoutedSsePayload(raw: unknown): RoutedOpencodeEvent | null {
if (!raw || typeof raw !== 'object') {
return null;
}
const record = raw as Record<string, unknown>;
const directoryCandidate =
typeof record.directory === 'string'
? record.directory
: typeof record.properties === 'object' && record.properties !== null
? ((record.properties as Record<string, unknown>).directory as unknown)
: null;
const normalizedDirectory =
typeof directoryCandidate === 'string'
? this.normalizeCandidatePath(directoryCandidate) ?? directoryCandidate.trim()
: null;
if (typeof record.type === 'string') {
return {
directory: normalizedDirectory && normalizedDirectory.length > 0 ? normalizedDirectory : 'global',
payload: record as Event,
};
}
const nestedPayload = record.payload;
if (nestedPayload && typeof nestedPayload === 'object') {
const nestedRecord = nestedPayload as Record<string, unknown>;
if (typeof nestedRecord.type === 'string') {
return {
directory: normalizedDirectory && normalizedDirectory.length > 0 ? normalizedDirectory : 'global',
payload: nestedRecord as Event,
};
}
}
return null;
}
private emitGlobalSseEvent(event: RoutedOpencodeEvent) {
for (const listener of this.globalSseListeners) {
try {
listener(event);
} catch (error) {
console.warn('[OpencodeClient] Global SSE listener error:', error);
}
}
}
private notifyGlobalSseOpen() {
for (const handler of this.globalSseOpenListeners) {
try {
handler();
} catch (error) {
console.warn('[OpencodeClient] Global SSE open handler error:', error);
}
}
}
private notifyGlobalSseError(error: unknown) {
for (const handler of this.globalSseErrorListeners) {
try {
handler(error);
} catch (listenerError) {
console.warn('[OpencodeClient] Global SSE error handler failed:', listenerError);
}
}
}
private ensureGlobalSseStarted() {
if (this.globalSseTask) {
return;
}
const abortController = new AbortController();
this.globalSseAbortController = abortController;
this.globalSseTask = this.runGlobalSseLoop(abortController)
.catch((error) => {
if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) {
return;
}
console.error('[OpencodeClient] Global SSE task failed:', error);
})
.finally(() => {
if (this.globalSseAbortController === abortController) {
this.globalSseAbortController = null;
}
this.globalSseTask = null;
this.globalSseIsConnected = false;
});
}
private maybeStopGlobalSse() {
if (this.globalSseListeners.size > 0) {
return;
}
if (this.globalSseAbortController && !this.globalSseAbortController.signal.aborted) {
this.globalSseAbortController.abort();
}
this.globalSseAbortController = null;
}
private async runGlobalSseLoop(abortController: AbortController): Promise<void> {
const globalEndpoint = `${this.baseUrl.replace(/\/+$/, '')}/global/event`;
let attempt = 0;
while (!abortController.signal.aborted) {
try {
const headers: Record<string, string> = {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
};
if (this.globalSseLastEventId) {
headers['Last-Event-ID'] = this.globalSseLastEventId;
}
const response = await fetch(globalEndpoint, {
method: 'GET',
headers,
signal: abortController.signal,
});
if (!response.ok || !response.body) {
throw new Error(`Global SSE connect failed with status ${response.status}`);
}
attempt = 0;
this.globalSseIsConnected = true;
if (!abortController.signal.aborted) {
this.notifyGlobalSseOpen();
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (abortController.signal.aborted) break;
if (!value || value.length === 0) continue;
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
const blocks = buffer.split('\n\n');
buffer = blocks.pop() ?? '';
for (const block of blocks) {
const parsed = this.parseSseBlock(block);
if (!parsed) continue;
if (parsed.id) {
this.globalSseLastEventId = parsed.id;
}
const routed = this.normalizeRoutedSsePayload(parsed.data);
if (routed) {
this.emitGlobalSseEvent(routed);
}
}
}
const remaining = buffer.trim();
if (remaining && !abortController.signal.aborted) {
const parsed = this.parseSseBlock(remaining);
if (parsed?.id) {
this.globalSseLastEventId = parsed.id;
}
const routed = parsed ? this.normalizeRoutedSsePayload(parsed.data) : null;
if (routed) {
this.emitGlobalSseEvent(routed);
}
}
// Stream ended; force reconnect.
this.globalSseIsConnected = false;
} catch (error: unknown) {
this.globalSseIsConnected = false;
if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) {
return;
}
console.error('[OpencodeClient] Global SSE stream error (will retry):', error);
this.notifyGlobalSseError(error);
}
if (abortController.signal.aborted) {
break;
}
attempt += 1;
const delay = Math.min(3000 * Math.pow(2, attempt), 30000);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
subscribeToGlobalEvents(
onEvent: (event: RoutedOpencodeEvent) => void,
onError?: (error: unknown) => void,
onOpen?: () => void,
options?: { directory?: string | null }
): () => void {
const directoryFilter = this.normalizeCandidatePath(options?.directory ?? null);
const listener = (event: RoutedOpencodeEvent) => {
if (directoryFilter && event.directory !== directoryFilter) {
return;
}
onEvent(event);
};
this.globalSseListeners.add(listener);
if (onOpen) {
this.globalSseOpenListeners.add(onOpen);
if (this.globalSseIsConnected) {
setTimeout(() => {
if (this.globalSseOpenListeners.has(onOpen)) {
try {
onOpen();
} catch (error) {
console.warn('[OpencodeClient] Global SSE open handler error:', error);
}
}
}, 0);
}
}
if (onError) {
this.globalSseErrorListeners.add(onError);
}
this.ensureGlobalSseStarted();
return () => {
this.globalSseListeners.delete(listener);
if (onOpen) {
this.globalSseOpenListeners.delete(onOpen);
}
if (onError) {
this.globalSseErrorListeners.delete(onError);
}
this.maybeStopGlobalSse();
};
}
// Event Streaming using SDK SSE (Server-Sent Events) with AsyncGenerator
subscribeToEvents(
onMessage: (event: { type: string; properties?: Record<string, unknown> }) => void,
@@ -839,6 +1158,7 @@ class OpencodeService {
options?: { scope?: 'global' | 'directory'; key?: string }
): () => void {
const subscriptionKey = options?.key ?? 'default';
const scope = options?.scope ?? 'directory';
const existingController = this.sseAbortControllers.get(subscriptionKey);
if (existingController) {
existingController.abort();
@@ -850,17 +1170,122 @@ class OpencodeService {
let lastEventId: string | undefined;
if (scope === 'global') {
let globalUnsub: (() => void) | null = null;
const attachDirectory = (event: RoutedOpencodeEvent): Event => {
if (event.directory === 'global') {
return event.payload;
}
const payloadRecord = event.payload as unknown as Record<string, unknown>;
const existingProperties =
typeof payloadRecord.properties === 'object' && payloadRecord.properties !== null
? (payloadRecord.properties as Record<string, unknown>)
: {};
if (existingProperties.directory === event.directory) {
return event.payload;
}
return {
...payloadRecord,
properties: {
...existingProperties,
directory: event.directory,
},
} as Event;
};
const cleanup = () => {
if (globalUnsub) {
try {
globalUnsub();
} catch {
// ignore
}
globalUnsub = null;
}
if (this.sseAbortControllers.get(subscriptionKey) === abortController) {
this.sseAbortControllers.delete(subscriptionKey);
}
};
abortController.signal.addEventListener('abort', cleanup, { once: true });
globalUnsub = this.subscribeToGlobalEvents(
(event) => {
if (abortController.signal.aborted) {
return;
}
onMessage(attachDirectory(event));
},
onError
? (error) => {
if (!abortController.signal.aborted) {
onError(error);
}
}
: undefined,
onOpen
? () => {
if (!abortController.signal.aborted) {
onOpen();
}
}
: undefined,
);
return () => {
cleanup();
abortController.abort();
};
}
const normalizeEventPayload = (payload: unknown): Event | null => {
if (!payload || typeof payload !== 'object') {
return null;
}
const record = payload as Record<string, unknown>;
if (typeof record.type === 'string') {
return record as Event;
}
const nestedPayload = record.payload;
if (nestedPayload && typeof nestedPayload === 'object') {
const nestedRecord = nestedPayload as Record<string, unknown>;
if (typeof nestedRecord.type === 'string') {
if (typeof record.directory === 'string' && record.directory.length > 0) {
const existingProperties =
typeof nestedRecord.properties === 'object' && nestedRecord.properties !== null
? (nestedRecord.properties as Record<string, unknown>)
: null;
const properties = {
...(existingProperties ?? {}),
directory: record.directory,
};
return { ...nestedRecord, properties } as Event;
}
return nestedRecord as Event;
}
}
return null;
};
console.log('[OpencodeClient] Starting SSE subscription...');
// Start async generator in background with reconnect on failure
(async () => {
const resolvedDirectory =
options?.scope === 'global'
? undefined
: typeof directoryOverride === 'string' && directoryOverride.trim().length > 0
? directoryOverride.trim()
: this.currentDirectory;
console.log('[OpencodeClient] Connecting to SSE with directory:', resolvedDirectory ?? 'global');
typeof directoryOverride === 'string' && directoryOverride.trim().length > 0
? directoryOverride.trim()
: this.currentDirectory;
console.log('[OpencodeClient] Connecting to SSE with directory:', resolvedDirectory ?? 'default');
const connect = async (attempt: number): Promise<void> => {
try {
@@ -891,8 +1316,9 @@ class OpencodeService {
lastEventId = event.id;
}
const payload = event.data;
if (payload && typeof payload === 'object') {
onMessage(payload as Event);
const normalized = normalizeEventPayload(payload);
if (normalized) {
onMessage(normalized);
}
},
};
@@ -915,13 +1341,6 @@ class OpencodeService {
break;
}
}
if (!abortController.signal.aborted) {
// Stream ended unexpectedly; attempt reconnect
const delay = Math.min(3000 * Math.pow(2, attempt), 30000);
await new Promise((resolve) => setTimeout(resolve, delay));
await connect(attempt + 1);
}
} catch (error: unknown) {
if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) {
console.log('[OpencodeClient] SSE stream aborted normally');
@@ -936,6 +1355,13 @@ class OpencodeService {
if (!abortController.signal.aborted) {
await connect(attempt + 1);
}
return;
}
if (!abortController.signal.aborted) {
const delay = Math.min(3000 * Math.pow(2, attempt), 30000);
await new Promise((resolve) => setTimeout(resolve, delay));
await connect(attempt + 1);
}
};
@@ -956,6 +1382,7 @@ class OpencodeService {
}
abortController.abort();
};
}
// File Operations
@@ -1092,7 +1519,7 @@ class OpencodeService {
const healthData = await response.json();
// Check if OpenCode is actually ready (not just OpenChamber server)
// Check if the upstream API is ready (not just OpenChamber server)
if (healthData.isOpenCodeReady === false) {
return false;
}
+68
View File
@@ -32,6 +32,16 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
localStorage.setItem('homeDirectory', settings.homeDirectory);
window.__OPENCHAMBER_HOME__ = settings.homeDirectory;
}
if (Array.isArray(settings.projects) && settings.projects.length > 0) {
localStorage.setItem('projects', JSON.stringify(settings.projects));
} else {
localStorage.removeItem('projects');
}
if (settings.activeProjectId) {
localStorage.setItem('activeProjectId', settings.activeProjectId);
} else {
localStorage.removeItem('activeProjectId');
}
if (Array.isArray(settings.pinnedDirectories) && settings.pinnedDirectories.length > 0) {
localStorage.setItem('pinnedDirectories', JSON.stringify(settings.pinnedDirectories));
} else {
@@ -78,6 +88,55 @@ const sanitizeSkillCatalogs = (value: unknown): DesktopSettings['skillCatalogs']
return result;
};
const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefined => {
if (!Array.isArray(value)) {
return undefined;
}
const result: NonNullable<DesktopSettings['projects']> = [];
const seenIds = new Set<string>();
const seenPaths = new Set<string>();
for (const entry of value) {
if (!entry || typeof entry !== 'object') continue;
const candidate = entry as Record<string, unknown>;
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : '';
if (!id || !rawPath) continue;
const normalizedPath = rawPath === '/' ? rawPath : rawPath.replace(/\\/g, '/').replace(/\/+$/, '');
if (!normalizedPath) continue;
if (seenIds.has(id) || seenPaths.has(normalizedPath)) continue;
seenIds.add(id);
seenPaths.add(normalizedPath);
const project: NonNullable<DesktopSettings['projects']>[number] = {
id,
path: normalizedPath,
};
if (typeof candidate.label === 'string' && candidate.label.trim().length > 0) {
project.label = candidate.label.trim();
}
if (typeof candidate.addedAt === 'number' && Number.isFinite(candidate.addedAt) && candidate.addedAt >= 0) {
project.addedAt = candidate.addedAt;
}
if (
typeof candidate.lastOpenedAt === 'number' &&
Number.isFinite(candidate.lastOpenedAt) &&
candidate.lastOpenedAt >= 0
) {
project.lastOpenedAt = candidate.lastOpenedAt;
}
result.push(project);
}
return result.length > 0 ? result : undefined;
};
const getPersistApi = (): PersistApi | undefined => {
const candidate = (useUIStore as unknown as { persist?: PersistApi }).persist;
if (candidate && typeof candidate === 'object') {
@@ -138,6 +197,15 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) {
result.homeDirectory = candidate.homeDirectory;
}
const projects = sanitizeProjects(candidate.projects);
if (projects) {
result.projects = projects;
}
if (typeof candidate.activeProjectId === 'string' && candidate.activeProjectId.length > 0) {
result.activeProjectId = candidate.activeProjectId;
}
if (Array.isArray(candidate.approvedDirectories)) {
result.approvedDirectories = candidate.approvedDirectories.filter(
(entry): entry is string => typeof entry === 'string' && entry.length > 0
+1
View File
@@ -11,6 +11,7 @@ export type SessionDeleteRequest = {
export type SessionCreateRequest = {
worktreeMode?: 'main' | 'create' | 'reuse';
parentID?: string | null;
projectId?: string | null;
};
type DeleteListener = (request: SessionDeleteRequest) => void;
+2 -10
View File
@@ -280,16 +280,8 @@ const resolveSessionDirectory = async (sessionId: string | null | undefined): Pr
try {
const sessionStore = useSessionStore.getState();
const metadata = sessionStore.getWorktreeMetadata(sessionId);
if (metadata?.path) {
return metadata.path;
}
const session = sessionStore.sessions.find((entry) => entry.id === sessionId) as { directory?: string } | undefined;
const sessionDirectory =
typeof session?.directory === 'string' && session.directory.length > 0 ? session.directory : undefined;
return sessionDirectory;
const directory = sessionStore.getDirectoryForSession(sessionId);
return directory ?? undefined;
} catch (error) {
console.warn('Failed to resolve session directory override:', error);
return undefined;
+18 -21
View File
@@ -1,19 +1,19 @@
import { create } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware";
import { opencodeClient } from "@/lib/opencode/client";
import type { Permission, PermissionResponse } from "@/types/permission";
import type { PermissionRequest, PermissionResponse } from "@/types/permission";
import { isEditPermissionType, getAgentDefaultEditPermission } from "./utils/permissionUtils";
import { getSafeStorage } from "./utils/safeStorage";
import { useMessageStore } from "./messageStore";
import { useSessionStore } from "./sessionStore";
interface PermissionState {
permissions: Map<string, Permission[]>;
permissions: Map<string, PermissionRequest[]>;
}
interface PermissionActions {
addPermission: (permission: Permission, contextData?: { currentAgentContext?: Map<string, string>, sessionAgentSelections?: Map<string, string>, getSessionAgentEditMode?: (sessionId: string, agentName: string | undefined) => string }) => void;
respondToPermission: (sessionId: string, permissionId: string, response: PermissionResponse) => Promise<void>;
addPermission: (permission: PermissionRequest, contextData?: { currentAgentContext?: Map<string, string>, sessionAgentSelections?: Map<string, string>, getSessionAgentEditMode?: (sessionId: string, agentName: string | undefined) => string }) => void;
respondToPermission: (sessionId: string, requestId: string, response: PermissionResponse) => Promise<void>;
}
type PermissionStore = PermissionState & PermissionActions;
@@ -21,11 +21,11 @@ type PermissionStore = PermissionState & PermissionActions;
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
const sanitizePermissionEntries = (value: unknown): Array<[string, Permission[]]> => {
const sanitizePermissionEntries = (value: unknown): Array<[string, PermissionRequest[]]> => {
if (!Array.isArray(value)) {
return [];
}
const entries: Array<[string, Permission[]]> = [];
const entries: Array<[string, PermissionRequest[]]> = [];
value.forEach((entry) => {
if (!Array.isArray(entry) || entry.length !== 2) {
return;
@@ -34,7 +34,7 @@ const sanitizePermissionEntries = (value: unknown): Array<[string, Permission[]]
if (typeof sessionId !== "string" || !Array.isArray(permissions)) {
return;
}
entries.push([sessionId, permissions as Permission[]]);
entries.push([sessionId, permissions as PermissionRequest[]]);
});
return entries;
};
@@ -42,15 +42,7 @@ const sanitizePermissionEntries = (value: unknown): Array<[string, Permission[]]
const executeWithPermissionDirectory = async <T>(sessionId: string, operation: () => Promise<T>): Promise<T> => {
try {
const sessionStore = useSessionStore.getState();
const metadata = sessionStore.getWorktreeMetadata(sessionId);
if (metadata?.path) {
return opencodeClient.withDirectory(metadata.path, operation);
}
const session = sessionStore.sessions.find((entry) => entry.id === sessionId) as { directory?: string } | undefined;
const directory =
typeof session?.directory === 'string' && session.directory.length > 0 ? session.directory : undefined;
const directory = sessionStore.getDirectoryForSession(sessionId);
if (directory) {
return opencodeClient.withDirectory(directory, operation);
}
@@ -67,13 +59,18 @@ export const usePermissionStore = create<PermissionStore>()(
permissions: new Map(),
addPermission: (permission: Permission, contextData?: { currentAgentContext?: Map<string, string>, sessionAgentSelections?: Map<string, string>, getSessionAgentEditMode?: (sessionId: string, agentName: string | undefined) => string }) => {
addPermission: (permission: PermissionRequest, contextData?: { currentAgentContext?: Map<string, string>, sessionAgentSelections?: Map<string, string>, getSessionAgentEditMode?: (sessionId: string, agentName: string | undefined) => string }) => {
const sessionId = permission.sessionID;
if (!sessionId) {
return;
}
const permissionType = permission.type?.toLowerCase?.() ?? null;
const existing = get().permissions.get(sessionId);
if (existing?.some((entry) => entry.id === permission.id)) {
return;
}
const permissionType = permission.permission?.toLowerCase?.() ?? null;
let agentName = contextData?.currentAgentContext?.get(sessionId);
if (!agentName) {
@@ -109,8 +106,8 @@ export const usePermissionStore = create<PermissionStore>()(
});
},
respondToPermission: async (sessionId: string, permissionId: string, response: PermissionResponse) => {
await executeWithPermissionDirectory(sessionId, () => opencodeClient.respondToPermission(sessionId, permissionId, response));
respondToPermission: async (sessionId: string, requestId: string, response: PermissionResponse) => {
await executeWithPermissionDirectory(sessionId, () => opencodeClient.replyToPermission(requestId, response));
if (response === 'reject') {
const messageStore = useMessageStore.getState();
@@ -120,7 +117,7 @@ export const usePermissionStore = create<PermissionStore>()(
set((state) => {
const sessionPermissions = state.permissions.get(sessionId) || [];
const updatedPermissions = sessionPermissions.filter((p) => p.id !== permissionId);
const updatedPermissions = sessionPermissions.filter((p) => p.id !== requestId);
const newPermissions = new Map(state.permissions);
newPermissions.set(sessionId, updatedPermissions);
return { permissions: newPermissions };
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -1,5 +1,5 @@
import type { Session, Message, Part } from "@opencode-ai/sdk/v2";
import type { Permission, PermissionResponse } from "@/types/permission";
import type { PermissionRequest, PermissionResponse } from "@/types/permission";
export interface AttachedFile {
id: string;
@@ -70,13 +70,14 @@ export type NewSessionDraftState = {
export interface SessionStore {
sessions: Session[];
sessionsByDirectory: Map<string, Session[]>;
currentSessionId: string | null;
lastLoadedDirectory: string | null;
messages: Map<string, { info: Message; parts: Part[] }[]>;
sessionMemoryState: Map<string, SessionMemoryState>;
messageStreamStates: Map<string, MessageStreamLifecycle>;
sessionCompactionUntil: Map<string, number>;
permissions: Map<string, Permission[]>;
permissions: Map<string, PermissionRequest[]>;
sessionAbortFlags: Map<string, { timestamp: number; acknowledged: boolean }>;
attachedFiles: AttachedFile[];
abortPromptSessionId: string | null;
@@ -96,6 +97,7 @@ export interface SessionStore {
webUICreatedSessions: Set<string>;
worktreeMetadata: Map<string, import('@/types/worktree').WorktreeMetadata>;
availableWorktrees: import('@/types/worktree').WorktreeMetadata[];
availableWorktreesByProject: Map<string, import('@/types/worktree').WorktreeMetadata[]>;
currentAgentContext: Map<string, string>;
@@ -139,10 +141,11 @@ export interface SessionStore {
markMessageStreamSettled: (messageId: string) => void;
updateMessageInfo: (sessionId: string, messageId: string, messageInfo: Message) => void;
updateSessionCompaction: (sessionId: string, compactingTimestamp?: number | null) => void;
addPermission: (permission: Permission) => void;
respondToPermission: (sessionId: string, permissionId: string, response: PermissionResponse) => Promise<void>;
addPermission: (permission: PermissionRequest) => void;
respondToPermission: (sessionId: string, requestId: string, response: PermissionResponse) => Promise<void>;
clearError: () => void;
getSessionsByDirectory: (directory: string) => Session[];
getDirectoryForSession: (sessionId: string) => string | null;
getLastMessageModel: (sessionId: string) => { providerID?: string; modelID?: string } | null;
getCurrentAgent: (sessionId: string) => string | undefined;
syncMessages: (sessionId: string, messages: { info: Message; parts: Part[] }[]) => void;
@@ -190,6 +193,7 @@ export interface SessionStore {
pollForTokenUpdates: (sessionId: string, messageId: string, maxAttempts?: number) => void;
updateSession: (session: Session) => void;
removeSessionFromStore: (sessionId: string) => void;
revertToMessage: (sessionId: string, messageId: string) => Promise<void>;
handleSlashUndo: (sessionId: string) => Promise<void>;
+520 -82
View File
@@ -2,6 +2,7 @@ import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { opencodeClient } from '@/lib/opencode/client';
import { useDirectoryStore } from './useDirectoryStore';
import { useProjectsStore } from './useProjectsStore';
import { useSessionStore } from './useSessionStore';
import type { WorktreeMetadata } from '@/types/worktree';
import { listWorktrees, mapWorktreeToMetadata } from '@/lib/git/worktreeService';
@@ -9,6 +10,28 @@ import type { Session } from '@opencode-ai/sdk/v2';
const OPENCHAMBER_DIR = '.openchamber';
const resolveProjectDirectory = (currentDirectory: string | null | undefined): string | null => {
const projectsState = useProjectsStore.getState();
const activeProjectId = projectsState.activeProjectId;
const activeProjectPath = activeProjectId
? projectsState.projects.find((project) => project.id === activeProjectId)?.path
: undefined;
if (typeof activeProjectPath === 'string' && activeProjectPath.trim().length > 0) {
return activeProjectPath;
}
const normalizedCurrent = typeof currentDirectory === 'string' ? normalize(currentDirectory) : '';
const marker = `/${OPENCHAMBER_DIR}/`;
const markerIndex = normalizedCurrent.indexOf(marker);
if (markerIndex > 0) {
return normalizedCurrent.slice(0, markerIndex);
}
return currentDirectory ? normalize(currentDirectory) : null;
};
/**
* Agent group session parsed from OpenCode session titles.
* Session titles follow pattern: `groupSlug/provider/model` or `groupSlug/provider/model/index`
@@ -69,12 +92,16 @@ interface AgentGroupsActions {
selectGroup: (groupName: string | null) => void;
/** Select a session within the current group */
selectSession: (sessionId: string | null) => void;
/** Delete the entire group (all worktrees + sessions in those worktrees). */
deleteGroup: (groupName: string) => Promise<boolean>;
/** Delete a single worktree within a group (and all sessions in that worktree). */
deleteGroupWorktree: (groupName: string, worktreePath: string) => Promise<boolean>;
/** Keep one worktree and remove all others in the group. */
keepOnlyGroupWorktree: (groupName: string, keepWorktreePath: string) => Promise<boolean>;
/** Get the currently selected group */
getSelectedGroup: () => AgentGroup | null;
/** Get the currently selected session */
getSelectedSession: () => AgentGroupSession | null;
/** Delete a group and all its sessions, archiving worktrees */
deleteGroup: (groupName: string) => Promise<{ success: boolean; deletedCount: number; failedCount: number }>;
/** Clear error */
clearError: () => void;
}
@@ -88,6 +115,214 @@ const normalize = (value: string): string => {
return replaced.replace(/\/+$/, '');
};
const buildOpenChamberRoot = (projectDirectory: string): string => {
const normalizedProject = normalize(projectDirectory);
if (!normalizedProject || normalizedProject === '/') {
return `/${OPENCHAMBER_DIR}`;
}
return `${normalizedProject}/${OPENCHAMBER_DIR}`;
};
const resolveDirectoryListingPaths = (root: string, entries: Array<{ name?: string; path?: string }>): string[] => {
const normalizedRoot = normalize(root);
return entries
.map((entry) => {
const entryPath = typeof entry.path === 'string' && entry.path.trim().length > 0 ? entry.path : null;
if (entryPath) {
const normalizedEntry = normalize(entryPath);
if (normalizedEntry) {
return normalizedEntry;
}
}
const name = typeof entry.name === 'string' ? entry.name.trim() : '';
if (!name || !normalizedRoot) {
return null;
}
return `${normalizedRoot}/${name}`;
})
.filter((value): value is string => Boolean(value));
};
const listOpenChamberDirectories = async (root: string): Promise<string[]> => {
const normalizedRoot = normalize(root);
if (!normalizedRoot) {
return [];
}
try {
const entries = await opencodeClient.listLocalDirectory(normalizedRoot);
const directories = entries.filter((entry) => entry.isDirectory);
return resolveDirectoryListingPaths(normalizedRoot, directories);
} catch {
return [];
}
};
const startsWithDirectory = (candidate: string, root: string): boolean => {
const normalizedCandidate = normalize(candidate);
const normalizedRoot = normalize(root);
if (!normalizedCandidate || !normalizedRoot) {
return false;
}
if (normalizedCandidate === normalizedRoot) {
return true;
}
const prefix = normalizedRoot === '/' ? '/' : `${normalizedRoot}/`;
return normalizedCandidate.startsWith(prefix);
};
const resolveCanonicalDirectory = async (
apiClient: ReturnType<typeof opencodeClient.getApiClient>,
directory: string
): Promise<string> => {
const normalized = normalize(directory);
if (!normalized) {
return normalized;
}
try {
const response = await apiClient.path.get({ directory: normalized });
const canonical = normalize((response.data as { directory?: string | null } | null)?.directory ?? '');
return canonical || normalized;
} catch {
return normalized;
}
};
const listSessionsForDirectory = async (
apiClient: ReturnType<typeof opencodeClient.getApiClient>,
directory: string
): Promise<Session[]> => {
const normalized = normalize(directory);
if (!normalized) {
return [];
}
const canonical = await resolveCanonicalDirectory(apiClient, normalized);
const filterToDirectory = (sessions: Session[]) => {
return sessions.filter((session) => {
const dir = normalize((session as { directory?: string | null }).directory ?? '');
if (!dir) return false;
return startsWithDirectory(dir, normalized) || (canonical !== normalized && startsWithDirectory(dir, canonical));
});
};
const attemptList = async (dir: string) => {
const response = await apiClient.session.list({ directory: dir });
return Array.isArray(response.data) ? response.data : [];
};
try {
const list = filterToDirectory(await attemptList(normalized));
if (list.length > 0) {
return list;
}
} catch {
// ignore
}
if (canonical && canonical !== normalized) {
try {
const list = filterToDirectory(await attemptList(canonical));
if (list.length > 0) {
return list;
}
} catch {
// ignore
}
}
try {
const global = await apiClient.session.list(undefined);
const list = Array.isArray(global.data) ? global.data : [];
return filterToDirectory(list);
} catch {
return [];
}
};
const buildWorktreeMetadataByPath = async (group: AgentGroup, projectDirectory: string): Promise<Map<string, WorktreeMetadata>> => {
const map = new Map<string, WorktreeMetadata>();
group.sessions.forEach((session) => {
if (session.worktreeMetadata) {
map.set(normalize(session.path), session.worktreeMetadata);
}
});
const missingPaths = Array.from(new Set(group.sessions.map((session) => normalize(session.path))))
.filter(Boolean)
.filter((path) => !map.has(path));
if (missingPaths.length === 0) {
return map;
}
try {
const infos = await listWorktrees(projectDirectory);
const infoByPath = new Map(infos.map((info) => [normalize(info.worktree), info]));
missingPaths.forEach((path) => {
const info = infoByPath.get(path);
if (info) {
map.set(path, mapWorktreeToMetadata(projectDirectory, info));
}
});
} catch {
// ignore
}
return map;
};
const collectDeleteCandidates = async (params: {
apiClient: ReturnType<typeof opencodeClient.getApiClient>;
group: AgentGroup;
projectDirectory: string;
worktreePaths: string[];
}): Promise<Array<{ worktreePath: string; sessionIds: string[]; metadata?: WorktreeMetadata }>> => {
const { apiClient, group, projectDirectory, worktreePaths } = params;
const metadataByPath = await buildWorktreeMetadataByPath(group, projectDirectory);
const sessionStore = useSessionStore.getState();
const uniqueWorktreePaths = Array.from(new Set(worktreePaths.map((path) => normalize(path)).filter(Boolean)));
const concurrency = 5;
let index = 0;
const results: Array<{ worktreePath: string; sessionIds: string[]; metadata?: WorktreeMetadata }> = [];
const worker = async () => {
while (index < uniqueWorktreePaths.length) {
const current = uniqueWorktreePaths[index];
index += 1;
const sessionsInGroup = group.sessions.filter((session) => normalize(session.path) === current).map((session) => session.id);
const cached = sessionStore.getSessionsByDirectory(current);
const cachedIds = Array.isArray(cached) ? cached.map((session) => session.id) : [];
// Prefer the session store cache (already directory-partitioned). If empty, fall back to direct API listing.
let listedIds: string[] = [];
if (cachedIds.length === 0) {
try {
const listed = await listSessionsForDirectory(apiClient, current);
listedIds = listed.map((session) => session.id);
} catch {
listedIds = [];
}
}
const ids = Array.from(new Set([...cachedIds, ...listedIds, ...sessionsInGroup].filter(Boolean)));
results.push({
worktreePath: current,
sessionIds: ids,
metadata: metadataByPath.get(current),
});
}
};
await Promise.all(Array.from({ length: Math.min(concurrency, uniqueWorktreePaths.length) }, worker));
return results;
};
/**
* Parse a session title to extract group, provider, model, and index.
* Title format: groupSlug/provider/model[/index]
@@ -158,31 +393,29 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
loadGroups: async () => {
const currentDirectory = useDirectoryStore.getState().currentDirectory;
if (!currentDirectory) {
const projectDirectory = resolveProjectDirectory(currentDirectory);
if (!projectDirectory) {
set({ groups: [], isLoading: false, error: 'No project directory selected' });
return;
}
// Check if we're inside a .openchamber worktree - if so, don't reload
// This prevents groups from disappearing when switching to a worktree session
const normalizedCurrent = normalize(currentDirectory);
if (normalizedCurrent.includes(`/${OPENCHAMBER_DIR}/`)) {
// We're inside a worktree, don't reload groups
set({ isLoading: false });
return;
}
const normalizedProject = normalize(projectDirectory);
const openChamberRoot = buildOpenChamberRoot(normalizedProject);
const previousGroups = get().groups;
set({ isLoading: true, error: null });
try {
const apiClient = opencodeClient.getApiClient();
const canonicalProject = await resolveCanonicalDirectory(apiClient, normalizedProject);
const openChamberRootCanonical = buildOpenChamberRoot(canonicalProject);
// Get git worktree info first - we need to query each worktree separately
let worktreeInfoMap = new Map<string, Awaited<ReturnType<typeof listWorktrees>>[number]>();
let worktreeInfoList: Awaited<ReturnType<typeof listWorktrees>> = [];
try {
worktreeInfoList = await listWorktrees(normalizedCurrent);
worktreeInfoList = await listWorktrees(normalizedProject);
worktreeInfoMap = new Map(
worktreeInfoList.map((info) => [normalize(info.worktree), info])
);
@@ -190,32 +423,90 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
console.debug('Failed to list git worktrees');
}
// Fetch sessions from each worktree directory (sessions are stored per-directory in OpenCode)
// Filter to only .openchamber worktrees (agent group worktrees)
const openchamberWorktrees = worktreeInfoList.filter(
(info) => normalize(info.worktree).includes(`/${OPENCHAMBER_DIR}/`)
);
const sessionsMap = new Map<string, Session>();
// Fetch sessions from each openchamber worktree
await Promise.all(
openchamberWorktrees.map(async (worktree) => {
try {
const response = await apiClient.session.list({
directory: normalize(worktree.worktree),
});
const sessions: Session[] = Array.isArray(response.data) ? response.data : [];
for (const session of sessions) {
sessionsMap.set(session.id, session);
}
} catch (err) {
console.debug('Failed to fetch sessions from worktree:', worktree.worktree, err);
const fetchCandidateSessions = async (): Promise<Session[]> => {
try {
const scoped = await apiClient.session.list({ directory: normalizedProject });
const list = Array.isArray(scoped.data) ? scoped.data : [];
if (list.some((session) => {
const dir = normalize((session as { directory?: string | null }).directory ?? '');
return startsWithDirectory(dir, openChamberRoot) || startsWithDirectory(dir, openChamberRootCanonical);
})) {
return list;
}
})
);
const allSessions = Array.from(sessionsMap.values());
} catch {
// ignore and fall back to global list
}
const global = await apiClient.session.list(undefined);
return Array.isArray(global.data) ? global.data : [];
};
const fetchSessionsByWorktreeDirectories = async (directories: string[]): Promise<Session[]> => {
const sessionsMap = new Map<string, Session>();
const concurrency = 5;
let index = 0;
const worker = async () => {
while (index < directories.length) {
const current = directories[index];
index += 1;
const normalizedDir = normalize(current);
if (!normalizedDir) continue;
try {
const sessions = await listSessionsForDirectory(apiClient, normalizedDir);
sessions.forEach((session) => sessionsMap.set(session.id, session));
} catch (err) {
console.debug('Failed to fetch sessions from worktree:', normalizedDir, err);
}
}
};
await Promise.all(Array.from({ length: Math.min(concurrency, directories.length) }, worker));
return Array.from(sessionsMap.values());
};
const candidateSessions = await fetchCandidateSessions();
let allSessions = candidateSessions.filter((session) => {
const dir = normalize((session as { directory?: string | null }).directory ?? '');
if (!dir) {
return false;
}
return startsWithDirectory(dir, openChamberRoot) || startsWithDirectory(dir, openChamberRootCanonical);
});
// Some OpenCode builds do not return sessions across directories in the global list.
// If we didn't discover any group sessions, fall back to querying each `.openchamber` worktree directory directly.
if (allSessions.length === 0) {
const candidates = new Set<string>();
// 1) Git worktree list
worktreeInfoList
.map((info) => normalize(info.worktree))
.filter((worktreePath) =>
startsWithDirectory(worktreePath, openChamberRoot) || startsWithDirectory(worktreePath, openChamberRootCanonical)
)
.forEach((worktreePath) => candidates.add(worktreePath));
// 2) Filesystem scan (handles cases where git worktree listing breaks or isn't available)
const roots = Array.from(new Set([openChamberRoot, openChamberRootCanonical].map((p) => normalize(p)).filter(Boolean)));
await Promise.all(
roots.map(async (root) => {
const dirs = await listOpenChamberDirectories(root);
dirs.forEach((dir) => candidates.add(dir));
})
);
if (candidates.size > 0) {
allSessions = await fetchSessionsByWorktreeDirectories(Array.from(candidates));
}
}
const sessionUpdatedAtById = new Map<string, number>();
for (const session of allSessions) {
const updatedAt = (session as { time?: { updated?: number | null } }).time?.updated ?? 0;
sessionUpdatedAtById.set(session.id, typeof updatedAt === 'number' ? updatedAt : 0);
}
// Parse sessions and group by groupSlug
const groupsMap = new Map<string, AgentGroupSession[]>();
@@ -236,7 +527,7 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
branch: worktreeInfo?.branch ?? '',
displayLabel: `${parsed.provider}/${parsed.model}`,
worktreeMetadata: worktreeInfo
? mapWorktreeToMetadata(normalizedCurrent, worktreeInfo)
? mapWorktreeToMetadata(normalizedProject, worktreeInfo)
: undefined,
};
@@ -253,9 +544,7 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
([name, sessions]) => {
// Find the most recent session update time for lastActive
const lastActive = sessions.reduce((max, s) => {
// Find the original session to get the time
const originalSession = allSessions.find((os) => os.id === s.id);
const updatedTime = originalSession?.time?.updated ?? 0;
const updatedTime = sessionUpdatedAtById.get(s.id) ?? 0;
return Math.max(max, updatedTime);
}, 0);
@@ -305,6 +594,196 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
set({ selectedSessionId: sessionId });
},
deleteGroup: async (groupName) => {
const group = get().groups.find((g) => g.name === groupName);
if (!group) {
return false;
}
const currentDirectory = useDirectoryStore.getState().currentDirectory;
const projectDirectory = resolveProjectDirectory(currentDirectory);
if (!projectDirectory) {
set({ error: 'No project directory selected' });
return false;
}
set({ isLoading: true, error: null });
try {
const apiClient = opencodeClient.getApiClient();
const candidates = await collectDeleteCandidates({
apiClient,
group,
projectDirectory: normalize(projectDirectory),
worktreePaths: group.sessions.map((s) => s.path),
});
const sessionStore = useSessionStore.getState();
const ids = new Set<string>();
candidates.forEach(({ worktreePath, sessionIds, metadata }) => {
sessionIds.forEach((id) => {
ids.add(id);
if (metadata) {
sessionStore.setWorktreeMetadata(id, metadata);
sessionStore.setSessionDirectory(id, worktreePath);
}
});
});
const { failedIds } = await sessionStore.deleteSessions(Array.from(ids), { archiveWorktree: true, silent: true });
if (failedIds.length > 0) {
set({ error: 'Failed to delete some sessions' });
}
if (get().selectedGroupName === groupName) {
set({ selectedGroupName: null, selectedSessionId: null });
}
await get().loadGroups();
return failedIds.length === 0;
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to delete group' });
return false;
} finally {
set({ isLoading: false });
}
},
deleteGroupWorktree: async (groupName, worktreePath) => {
const group = get().groups.find((g) => g.name === groupName);
if (!group) {
return false;
}
const normalizedWorktreePath = normalize(worktreePath);
if (!normalizedWorktreePath) {
return false;
}
const currentDirectory = useDirectoryStore.getState().currentDirectory;
const projectDirectory = resolveProjectDirectory(currentDirectory);
if (!projectDirectory) {
set({ error: 'No project directory selected' });
return false;
}
set({ isLoading: true, error: null });
try {
const apiClient = opencodeClient.getApiClient();
const candidates = await collectDeleteCandidates({
apiClient,
group,
projectDirectory: normalize(projectDirectory),
worktreePaths: [normalizedWorktreePath],
});
const sessionStore = useSessionStore.getState();
const ids = new Set<string>();
candidates.forEach(({ worktreePath: resolvedPath, sessionIds, metadata }) => {
sessionIds.forEach((id) => {
ids.add(id);
if (metadata) {
sessionStore.setWorktreeMetadata(id, metadata);
sessionStore.setSessionDirectory(id, resolvedPath);
}
});
});
const { failedIds } = await sessionStore.deleteSessions(Array.from(ids), { archiveWorktree: true, silent: true });
if (failedIds.length > 0) {
set({ error: 'Failed to delete some sessions' });
}
await get().loadGroups();
const updated = get().groups.find((g) => g.name === groupName);
if (!updated) {
if (get().selectedGroupName === groupName) {
set({ selectedGroupName: null, selectedSessionId: null });
}
return failedIds.length === 0;
}
if (get().selectedGroupName === groupName) {
const currentSelected = get().selectedSessionId;
const remainingIds = new Set(updated.sessions.map((s) => s.id));
if (!currentSelected || !remainingIds.has(currentSelected)) {
set({ selectedSessionId: updated.sessions[0]?.id ?? null });
}
}
return failedIds.length === 0;
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to delete worktree' });
return false;
} finally {
set({ isLoading: false });
}
},
keepOnlyGroupWorktree: async (groupName, keepWorktreePath) => {
const group = get().groups.find((g) => g.name === groupName);
if (!group) {
return false;
}
const keepPath = normalize(keepWorktreePath);
if (!keepPath) {
return false;
}
const worktreePaths = Array.from(new Set(group.sessions.map((s) => normalize(s.path)).filter(Boolean)));
const toDelete = worktreePaths.filter((path) => path !== keepPath);
if (toDelete.length === 0) {
return true;
}
const currentDirectory = useDirectoryStore.getState().currentDirectory;
const projectDirectory = resolveProjectDirectory(currentDirectory);
if (!projectDirectory) {
set({ error: 'No project directory selected' });
return false;
}
set({ isLoading: true, error: null });
try {
const apiClient = opencodeClient.getApiClient();
const candidates = await collectDeleteCandidates({
apiClient,
group,
projectDirectory: normalize(projectDirectory),
worktreePaths: toDelete,
});
const sessionStore = useSessionStore.getState();
const ids = new Set<string>();
candidates.forEach(({ worktreePath, sessionIds, metadata }) => {
sessionIds.forEach((id) => {
ids.add(id);
if (metadata) {
sessionStore.setWorktreeMetadata(id, metadata);
sessionStore.setSessionDirectory(id, worktreePath);
}
});
});
const { failedIds } = await sessionStore.deleteSessions(Array.from(ids), { archiveWorktree: true, silent: true });
if (failedIds.length > 0) {
set({ error: 'Failed to delete some sessions' });
}
await get().loadGroups();
if (get().selectedGroupName === groupName) {
const updated = get().groups.find((g) => g.name === groupName);
const keepSession = updated?.sessions.find((s) => normalize(s.path) === keepPath) ?? updated?.sessions[0] ?? null;
set({ selectedSessionId: keepSession?.id ?? null });
}
return failedIds.length === 0;
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to remove other worktrees' });
return false;
} finally {
set({ isLoading: false });
}
},
getSelectedGroup: () => {
const { groups, selectedGroupName } = get();
if (!selectedGroupName) return null;
@@ -321,47 +800,6 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
clearError: () => {
set({ error: null });
},
deleteGroup: async (groupName: string) => {
const { groups, selectedGroupName } = get();
const group = groups.find((g) => g.name === groupName);
if (!group) {
return { success: false, deletedCount: 0, failedCount: 0 };
}
// Get all session IDs from the group
const sessionIds = group.sessions.map((s) => s.id);
if (sessionIds.length === 0) {
return { success: true, deletedCount: 0, failedCount: 0 };
}
// Delete sessions using sessionStore.deleteSessions
// archiveWorktree: true - removes the git worktree
// deleteRemoteBranch: false - does not delete remote branch
const { deletedIds, failedIds } = await useSessionStore.getState().deleteSessions(
sessionIds,
{
archiveWorktree: true,
deleteRemoteBranch: false,
}
);
// If the deleted group was selected, clear selection
if (selectedGroupName === groupName) {
set({ selectedGroupName: null, selectedSessionId: null });
}
// Reload groups to reflect changes
await get().loadGroups();
return {
success: failedIds.length === 0,
deletedCount: deletedIds.length,
failedCount: failedIds.length,
};
},
}),
{ name: 'agent-groups-store' }
)
+213 -87
View File
@@ -1,9 +1,9 @@
import { create } from "zustand";
import type { StoreApi, UseBoundStore } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware";
import type { Agent } from "@opencode-ai/sdk/v2";
import type { Agent, PermissionConfig } from "@opencode-ai/sdk/v2";
import { opencodeClient } from "@/lib/opencode/client";
import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync";
import { emitConfigChange, scopeMatches, subscribeToConfigChanges, type ConfigChangeScope } from "@/lib/configSync";
import {
startConfigUpdate,
finishConfigUpdate,
@@ -11,13 +11,20 @@ import {
} from "@/lib/configUpdate";
import { getSafeStorage } from "./utils/safeStorage";
import { useConfigStore } from "@/stores/useConfigStore";
import { useCommandsStore } from "@/stores/useCommandsStore";
import { useProjectsStore } from "@/stores/useProjectsStore";
import { useSkillsCatalogStore } from "@/stores/useSkillsCatalogStore";
import { useSkillsStore } from "@/stores/useSkillsStore";
// Note: useDirectoryStore cannot be imported at top level to avoid circular dependency
// useDirectoryStore -> useAgentsStore (for refreshAfterOpenCodeRestart)
// useAgentsStore -> useDirectoryStore (for currentDirectory)
// Instead we access it from the window object where it's exposed
const getCurrentDirectory = (): string | null => {
// Try to get from window if store is already loaded
const opencodeDirectory = opencodeClient.getDirectory();
if (typeof opencodeDirectory === 'string' && opencodeDirectory.trim().length > 0) {
return opencodeDirectory;
}
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const store = (window as any).__zustand_directory_store__;
@@ -27,6 +34,29 @@ const getCurrentDirectory = (): string | null => {
} catch {
// ignore
}
return null;
};
const getConfigDirectory = (): string | null => {
try {
const projectsStore = useProjectsStore.getState();
const activeProject = projectsStore.getActiveProject?.();
// 1. Primary: Active project path from store
if (activeProject?.path?.trim()) {
return activeProject.path.trim();
}
// 2. Fallback: current OpenCode directory (session / runtime)
const clientDir = opencodeClient.getDirectory();
if (clientDir?.trim()) {
return clientDir.trim();
}
} catch (err) {
console.warn('[AgentsStore] Error resolving config directory:', err);
}
return null;
};
@@ -40,15 +70,7 @@ export interface AgentConfig {
top_p?: number;
prompt?: string;
mode?: "primary" | "subagent" | "all";
tools?: Record<string, boolean>;
permission?: {
edit?: "allow" | "ask" | "deny";
bash?: "allow" | "ask" | "deny" | Record<string, "allow" | "ask" | "deny">;
skill?: "allow" | "ask" | "deny" | Record<string, "allow" | "ask" | "deny">;
webfetch?: "allow" | "ask" | "deny";
doom_loop?: "allow" | "ask" | "deny";
external_directory?: "allow" | "ask" | "deny";
};
permission?: PermissionConfig | null;
disable?: boolean;
scope?: AgentScope;
@@ -96,15 +118,7 @@ export interface AgentDraft {
top_p?: number;
prompt?: string;
mode?: "primary" | "subagent" | "all";
tools?: Record<string, boolean>;
permission?: {
edit?: "allow" | "ask" | "deny";
bash?: "allow" | "ask" | "deny" | Record<string, "allow" | "ask" | "deny">;
skill?: "allow" | "ask" | "deny" | Record<string, "allow" | "ask" | "deny">;
webfetch?: "allow" | "ask" | "deny";
doom_loop?: "allow" | "ask" | "deny";
external_directory?: "allow" | "ask" | "deny";
};
permission?: PermissionConfig;
disable?: boolean;
}
@@ -153,44 +167,67 @@ export const useAgentsStore = create<AgentsStore>()(
loadAgents: async () => {
set({ isLoading: true });
const previousAgents = get().agents;
let lastError: unknown = null;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const agents = await opencodeClient.listAgents();
// Fetch scope info for each agent
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const configDirectory = getConfigDirectory();
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
// Ensure we list agents using the correct project context
const agents = await opencodeClient.withDirectory(configDirectory, () => opencodeClient.listAgents());
const agentsWithScope = await Promise.all(
agents.map(async (agent) => {
try {
const response = await fetch(`/api/config/agents/${encodeURIComponent(agent.name)}${queryParams}`);
// Force no-cache to ensure we get the latest scope info
const response = await fetch(`/api/config/agents/${encodeURIComponent(agent.name)}${queryParams}`, {
headers: {
'Cache-Control': 'no-cache',
...(configDirectory ? { 'x-opencode-directory': configDirectory } : {}),
}
});
if (response.ok) {
const data = await response.json();
// Handle web/desktop response formats; fall back to JSON scope if md scope missing
const scope = data.scope ?? data.sources?.md?.scope ?? data.sources?.json?.scope;
return { ...agent, scope: scope as AgentScope | undefined };
// Prioritize explicit scope from server response
let scope = data.scope;
// Fallback to deducing from sources if top-level scope is missing
if (!scope && data.sources) {
const sources = data.sources;
scope = (sources.md?.exists ? sources.md.scope : undefined)
?? (sources.json?.exists ? sources.json.scope : undefined)
?? sources.md?.scope
?? sources.json?.scope;
}
if (scope === 'project' || scope === 'user') {
return { ...agent, scope: scope as AgentScope };
}
// Explicitly set null scope if not found, to clear stale state
return { ...agent, scope: undefined };
}
} catch {
// Ignore scope fetch errors and fall back to agent defaults
} catch (err) {
console.warn(`[AgentsStore] Failed to fetch config for agent ${agent.name}:`, err);
}
return agent;
})
);
set({ agents: agentsWithScope, isLoading: false });
if (JSON.stringify(previousAgents) !== JSON.stringify(agentsWithScope)) {
set({ agents: agentsWithScope, isLoading: false });
} else {
set({ isLoading: false });
}
return true;
} catch (error) {
lastError = error;
const waitMs = 200 * (attempt + 1);
await new Promise((resolve) => setTimeout(resolve, waitMs));
} catch {
// ignore error
}
}
console.error("Failed to load agents:", lastError);
set({ agents: previousAgents, isLoading: false });
set({ isLoading: false });
return false;
},
@@ -198,8 +235,10 @@ export const useAgentsStore = create<AgentsStore>()(
startConfigUpdate("Creating agent configuration…");
let requiresReload = false;
try {
console.log('[AgentsStore] Creating agent:', config.name);
const agentConfig: Record<string, unknown> = {
mode: config.mode || "subagent",
mode: config.mode || 'subagent',
};
if (config.description) agentConfig.description = config.description;
@@ -207,18 +246,21 @@ export const useAgentsStore = create<AgentsStore>()(
if (config.temperature !== undefined) agentConfig.temperature = config.temperature;
if (config.top_p !== undefined) agentConfig.top_p = config.top_p;
if (config.prompt) agentConfig.prompt = config.prompt;
if (config.tools && Object.keys(config.tools).length > 0) agentConfig.tools = config.tools;
if (config.permission) agentConfig.permission = config.permission;
if (config.disable !== undefined) agentConfig.disable = config.disable;
if (config.scope) agentConfig.scope = config.scope;
// Get current directory for project-level agent support
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
console.log('[AgentsStore] Agent config to save:', agentConfig);
const configDirectory = getConfigDirectory();
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
const response = await fetch(`/api/config/agents/${encodeURIComponent(config.name)}${queryParams}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(configDirectory ? { 'x-opencode-directory': configDirectory } : {}),
},
body: JSON.stringify(agentConfig)
});
@@ -231,9 +273,11 @@ export const useAgentsStore = create<AgentsStore>()(
const needsReload = payload?.requiresReload ?? true;
if (needsReload) {
requiresReload = true;
await performFullConfigRefresh({
await refreshAfterOpenCodeRestart({
message: payload?.message,
delayMs: payload?.reloadDelayMs,
scopes: ["agents"],
mode: "active",
});
return true;
}
@@ -243,7 +287,8 @@ export const useAgentsStore = create<AgentsStore>()(
emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE });
}
return loaded;
} catch {
} catch (error) {
console.error('Failed to create agent:', error);
return false;
} finally {
if (!requiresReload) {
@@ -264,17 +309,19 @@ export const useAgentsStore = create<AgentsStore>()(
if (config.temperature !== undefined) agentConfig.temperature = config.temperature;
if (config.top_p !== undefined) agentConfig.top_p = config.top_p;
if (config.prompt !== undefined) agentConfig.prompt = config.prompt;
if (config.tools !== undefined) agentConfig.tools = config.tools;
if (config.permission !== undefined) agentConfig.permission = config.permission;
if (config.disable !== undefined) agentConfig.disable = config.disable;
// Get current directory for project-level agent support
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
// Use active project root for project-level agent support.
const configDirectory = getConfigDirectory();
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
const response = await fetch(`/api/config/agents/${encodeURIComponent(name)}${queryParams}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(configDirectory ? { 'x-opencode-directory': configDirectory } : {}),
},
body: JSON.stringify(agentConfig)
});
@@ -287,9 +334,11 @@ export const useAgentsStore = create<AgentsStore>()(
const needsReload = payload?.requiresReload ?? true;
if (needsReload) {
requiresReload = true;
await performFullConfigRefresh({
await refreshAfterOpenCodeRestart({
message: payload?.message,
delayMs: payload?.reloadDelayMs,
scopes: ["agents"],
mode: "active",
});
return true;
}
@@ -299,8 +348,9 @@ export const useAgentsStore = create<AgentsStore>()(
emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE });
}
return loaded;
} catch {
return false;
} catch (error) {
console.error('Failed to update agent:', error);
throw error;
} finally {
if (!requiresReload) {
finishConfigUpdate();
@@ -312,12 +362,13 @@ export const useAgentsStore = create<AgentsStore>()(
startConfigUpdate("Deleting agent configuration…");
let requiresReload = false;
try {
// Get current directory for project-level agent support
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
// Use active project root for project-level agent support.
const configDirectory = getConfigDirectory();
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
const response = await fetch(`/api/config/agents/${encodeURIComponent(name)}${queryParams}`, {
method: 'DELETE'
method: 'DELETE',
headers: configDirectory ? { 'x-opencode-directory': configDirectory } : undefined,
});
const payload = await response.json().catch(() => null);
@@ -329,9 +380,11 @@ export const useAgentsStore = create<AgentsStore>()(
const needsReload = payload?.requiresReload ?? true;
if (needsReload) {
requiresReload = true;
await performFullConfigRefresh({
await refreshAfterOpenCodeRestart({
message: payload?.message,
delayMs: payload?.reloadDelayMs,
scopes: ["agents"],
mode: "active",
});
return true;
}
@@ -344,7 +397,6 @@ export const useAgentsStore = create<AgentsStore>()(
if (get().selectedAgentName === name) {
set({ selectedAgentName: null });
}
return loaded;
} catch {
return false;
@@ -355,6 +407,7 @@ export const useAgentsStore = create<AgentsStore>()(
}
},
getAgentByName: (name: string) => {
const { agents } = get();
return agents.find((a) => a.name === name);
@@ -427,45 +480,112 @@ async function waitForOpenCodeConnection(delayMs?: number) {
throw lastError || new Error("OpenCode did not become ready in time");
}
async function performFullConfigRefresh(options: { message?: string; delayMs?: number } = {}) {
type ConfigRefreshMode = "active" | "projects";
const normalizeRefreshScopes = (scopes?: ConfigChangeScope[]): ConfigChangeScope[] => {
if (!scopes || scopes.length === 0) {
return ["all"];
}
const unique = Array.from(new Set(scopes));
if (unique.includes("all")) {
return ["all"];
}
return unique;
};
async function performConfigRefresh(options: {
message?: string;
delayMs?: number;
scopes?: ConfigChangeScope[];
mode?: ConfigRefreshMode;
} = {}) {
const { message, delayMs } = options;
const scopes = normalizeRefreshScopes(options.scopes);
const mode: ConfigRefreshMode = options.mode ?? (scopes.includes("all") ? "projects" : "active");
try {
updateConfigUpdateMessage(message || "Reloading OpenCode configuration…");
if (typeof window !== "undefined" && window.localStorage) {
window.localStorage.removeItem("agents-store");
window.localStorage.removeItem("config-store");
}
updateConfigUpdateMessage(message || "Refreshing configuration…");
} catch {
// Ignore local storage cleanup errors
// ignore
}
try {
await waitForOpenCodeConnection(delayMs);
updateConfigUpdateMessage("Refreshing providers and agents…");
const configStore = useConfigStore.getState();
const agentsStore = useAgentsStore.getState();
const agentConfigStore = useAgentsStore.getState();
const commandsStore = useCommandsStore.getState();
const skillsStore = useSkillsStore.getState();
const skillsCatalogStore = useSkillsCatalogStore.getState();
await Promise.all([
configStore.loadProviders().then(() => undefined),
agentsStore.loadAgents().then(() => undefined),
]);
const refreshProviders = scopes.includes("all") || scopes.includes("providers");
const refreshSdkAgents = scopes.includes("all") || scopes.includes("agents");
const refreshAgentConfigs = scopes.includes("all") || scopes.includes("agents");
const refreshCommands = scopes.includes("all") || scopes.includes("commands");
const refreshSkills = scopes.includes("all") || scopes.includes("skills");
emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE });
const currentDirectory = getCurrentDirectory();
const projects = mode === "projects" ? useProjectsStore.getState().projects : [];
const directoriesToRefresh = Array.from(
new Set([
...(currentDirectory ? [currentDirectory] : []),
...projects.map((project) => project.path).filter(Boolean),
]),
);
if (scopes.includes("all") && mode === "projects") {
useConfigStore.setState({ directoryScoped: {} });
}
const sdkRefreshTasks: Promise<void>[] = [];
for (const directory of directoriesToRefresh) {
if (refreshProviders) {
sdkRefreshTasks.push(configStore.loadProviders({ directory }).then(() => undefined));
}
if (refreshSdkAgents) {
sdkRefreshTasks.push(configStore.loadAgents({ directory }).then(() => undefined));
}
}
const uiRefreshTasks: Promise<void>[] = [];
if (refreshAgentConfigs) {
uiRefreshTasks.push(agentConfigStore.loadAgents().then(() => undefined));
}
if (refreshCommands) {
uiRefreshTasks.push(commandsStore.loadCommands().then(() => undefined));
}
if (refreshSkills) {
uiRefreshTasks.push(skillsStore.loadSkills().then(() => undefined));
uiRefreshTasks.push(skillsCatalogStore.loadCatalog().then(() => undefined));
}
updateConfigUpdateMessage("Refreshing configuration…");
await Promise.all([...sdkRefreshTasks, ...uiRefreshTasks]);
} catch {
updateConfigUpdateMessage("OpenCode reload failed. Please retry refreshing configuration manually.");
updateConfigUpdateMessage("OpenCode refresh failed. Please retry.");
await sleep(1500);
} finally {
finishConfigUpdate();
}
}
export async function refreshAfterOpenCodeRestart(options?: { message?: string; delayMs?: number }) {
await performFullConfigRefresh(options);
export async function refreshAfterOpenCodeRestart(options?: {
message?: string;
delayMs?: number;
scopes?: ConfigChangeScope[];
mode?: ConfigRefreshMode;
}) {
await performConfigRefresh(options);
}
export async function reloadOpenCodeConfiguration(options?: { message?: string; delayMs?: number }) {
export async function reloadOpenCodeConfiguration(options?: {
message?: string;
delayMs?: number;
scopes?: ConfigChangeScope[];
mode?: ConfigRefreshMode;
}) {
startConfigUpdate(options?.message || "Reloading OpenCode configuration…");
try {
@@ -482,14 +602,20 @@ export async function reloadOpenCodeConfiguration(options?: { message?: string;
throw new Error(message);
}
const refreshOptions = {
...options,
scopes: options?.scopes ?? ["all"],
mode: options?.mode ?? "projects",
};
if (payload?.requiresReload) {
await performFullConfigRefresh({
await refreshAfterOpenCodeRestart({
...refreshOptions,
message: payload.message,
delayMs: payload.reloadDelayMs,
});
} else {
await performFullConfigRefresh(options);
await refreshAfterOpenCodeRestart(refreshOptions);
}
} catch (error) {
console.error('[reloadOpenCodeConfiguration] Failed:', error);
+89 -39
View File
@@ -9,8 +9,8 @@ import {
} from "@/lib/configUpdate";
import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync";
import { getSafeStorage } from "./utils/safeStorage";
import { useConfigStore } from "@/stores/useConfigStore";
import { useDirectoryStore } from "@/stores/useDirectoryStore";
import { useProjectsStore } from "@/stores/useProjectsStore";
export type CommandScope = 'user' | 'project';
@@ -37,6 +37,29 @@ export const isCommandBuiltIn = (command: Command): boolean => {
const CONFIG_EVENT_SOURCE = "useCommandsStore";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const getRequestDirectory = (): string | null => {
try {
const projectsStore = useProjectsStore.getState();
const activeProject = projectsStore.getActiveProject?.();
// 1. Primary: Active project path from store
if (activeProject?.path?.trim()) {
return activeProject.path.trim();
}
// 2. Fallback: current OpenCode directory (session / runtime)
const clientDir = opencodeClient.getDirectory();
if (clientDir?.trim()) {
return clientDir.trim();
}
} catch (err) {
console.warn('[CommandsStore] Error resolving config directory:', err);
}
return null;
};
const MAX_HEALTH_WAIT_MS = 20000;
const FAST_HEALTH_POLL_INTERVAL_MS = 300;
const FAST_HEALTH_POLL_ATTEMPTS = 4;
@@ -101,30 +124,60 @@ export const useCommandsStore = create<CommandsStore>()(
for (let attempt = 0; attempt < 3; attempt++) {
try {
const commands = await opencodeClient.listCommandsWithDetails();
const directory = getRequestDirectory();
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
// Fetch scope info for each command
const currentDirectory = useDirectoryStore.getState().currentDirectory;
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
// Ensure the list is scoped to the same directory we use for config source detection.
const commands = await opencodeClient.withDirectory(
directory,
() => opencodeClient.listCommandsWithDetails()
);
const commandsWithScope = await Promise.all(
commands.map(async (cmd) => {
try {
const response = await fetch(`/api/config/commands/${encodeURIComponent(cmd.name)}${queryParams}`);
// Force no-cache
const response = await fetch(`/api/config/commands/${encodeURIComponent(cmd.name)}${queryParams}`, {
headers: {
'Cache-Control': 'no-cache',
...(directory ? { 'x-opencode-directory': directory } : {}),
}
});
if (response.ok) {
const data = await response.json();
// Handle both web (data.scope) and desktop (data.sources.md.scope) response formats
const scope = data.scope ?? data.sources?.md?.scope;
return { ...cmd, scope: scope as CommandScope | undefined };
// Prioritize explicit scope
let scope = data.scope;
// Fallback to deducing from sources
if (!scope && data.sources) {
const sources = data.sources;
scope = (sources.md?.exists ? sources.md.scope : undefined)
?? (sources.json?.exists ? sources.json.scope : undefined)
?? sources.md?.scope
?? sources.json?.scope;
}
if (scope === 'project' || scope === 'user') {
return { ...cmd, scope: scope as CommandScope };
}
// Explicitly set null scope if not found
return { ...cmd, scope: undefined };
}
} catch {
// Ignore errors fetching scope
} catch (err) {
console.warn(`[CommandsStore] Failed to fetch config for command ${cmd.name}:`, err);
}
return cmd;
})
);
set({ commands: commandsWithScope, isLoading: false });
if (JSON.stringify(previousCommands) !== JSON.stringify(commandsWithScope)) {
set({ commands: commandsWithScope, isLoading: false });
} else {
set({ isLoading: false });
}
return true;
} catch (error) {
lastError = error;
@@ -156,13 +209,15 @@ export const useCommandsStore = create<CommandsStore>()(
console.log('[CommandsStore] Command config to save:', commandConfig);
// Get current directory for project-level command support
const currentDirectory = useDirectoryStore.getState().currentDirectory;
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const directory = getRequestDirectory();
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await fetch(`/api/config/commands/${encodeURIComponent(config.name)}${queryParams}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(directory ? { 'x-opencode-directory': directory } : {}),
},
body: JSON.stringify(commandConfig)
});
@@ -216,13 +271,15 @@ export const useCommandsStore = create<CommandsStore>()(
console.log('[CommandsStore] Command config to update:', commandConfig);
// Get current directory for project-level command support
const currentDirectory = useDirectoryStore.getState().currentDirectory;
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const directory = getRequestDirectory();
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await fetch(`/api/config/commands/${encodeURIComponent(name)}${queryParams}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(directory ? { 'x-opencode-directory': directory } : {}),
},
body: JSON.stringify(commandConfig)
});
@@ -263,12 +320,13 @@ export const useCommandsStore = create<CommandsStore>()(
startConfigUpdate("Deleting command configuration…");
let requiresReload = false;
try {
// Get current directory for project-level command support
const currentDirectory = useDirectoryStore.getState().currentDirectory;
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
// Use active project root for project-level command support
const directory = getRequestDirectory();
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await fetch(`/api/config/commands/${encodeURIComponent(name)}${queryParams}`, {
method: 'DELETE'
method: 'DELETE',
headers: directory ? { 'x-opencode-directory': directory } : undefined,
});
const payload = await response.json().catch(() => null);
@@ -380,31 +438,23 @@ async function performFullConfigRefresh(options: { message?: string; delayMs?: n
const { message, delayMs } = options;
try {
updateConfigUpdateMessage(message || "Reloading OpenCode configuration…");
if (typeof window !== "undefined" && window.localStorage) {
window.localStorage.removeItem("commands-store");
window.localStorage.removeItem("config-store");
}
} catch (error) {
console.warn("[CommandsStore] Failed to prepare config refresh:", error);
updateConfigUpdateMessage(message || "Refreshing commands…");
} catch {
// ignore
}
try {
await waitForOpenCodeConnection(delayMs);
updateConfigUpdateMessage("Refreshing providers and commands…");
updateConfigUpdateMessage("Refreshing commands…");
const configStore = useConfigStore.getState();
const commandsStore = useCommandsStore.getState();
await Promise.all([
configStore.loadProviders().then(() => undefined),
commandsStore.loadCommands().then(() => undefined),
]);
await commandsStore.loadCommands();
emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE });
} catch (error) {
console.error("[CommandsStore] Failed to refresh configuration after OpenCode restart:", error);
updateConfigUpdateMessage("OpenCode reload failed. Please retry refreshing configuration manually.");
updateConfigUpdateMessage("OpenCode refresh failed. Please retry refreshing configuration manually.");
await sleep(1500);
} finally {
finishConfigUpdate();
+561 -79
View File
@@ -11,6 +11,8 @@ import { filterVisibleAgents } from "./useAgentsStore";
import { isDesktopRuntime, getDesktopSettings } from "@/lib/desktop";
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry";
import { updateDesktopSettings } from "@/lib/persistence";
import { useDirectoryStore } from "@/stores/useDirectoryStore";
import { streamDebugEnabled } from "@/stores/utils/streamDebug";
const MODELS_DEV_API_URL = "https://models.dev/api.json";
const MODELS_DEV_PROXY_URL = "/api/openchamber/models-metadata";
@@ -271,10 +273,70 @@ const fetchModelsDevMetadata = async (): Promise<Map<string, ModelMetadata>> =>
return new Map();
};
let modelsMetadataInFlight: Promise<Map<string, ModelMetadata>> | null = null;
const ensureModelsMetadataFetch = (
getModelsMetadata: () => Map<string, ModelMetadata>,
setModelsMetadata: (metadata: Map<string, ModelMetadata>) => void,
) => {
const existing = getModelsMetadata();
if (existing.size > 0) {
return;
}
if (modelsMetadataInFlight) {
return;
}
modelsMetadataInFlight = fetchModelsDevMetadata()
.then((metadata) => {
if (metadata.size > 0) {
setModelsMetadata(metadata);
}
return metadata;
})
.catch(() => new Map<string, ModelMetadata>())
.finally(() => {
modelsMetadataInFlight = null;
});
};
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const DIRECTORY_KEY_GLOBAL = "__global__";
const toDirectoryKey = (directory: string | null | undefined): string => {
const trimmed = typeof directory === 'string' ? directory.trim() : '';
return trimmed.length > 0 ? trimmed : DIRECTORY_KEY_GLOBAL;
};
const fromDirectoryKey = (key: string): string | null => (key === DIRECTORY_KEY_GLOBAL ? null : key);
const resolveInitialDirectoryKey = (): string => {
if (typeof window === 'undefined') {
return DIRECTORY_KEY_GLOBAL;
}
const directory = opencodeClient.getDirectory() ?? useDirectoryStore.getState().currentDirectory;
return toDirectoryKey(directory);
};
interface DirectoryScopedConfig {
providers: ProviderWithModelList[];
agents: Agent[];
currentProviderId: string;
currentModelId: string;
currentAgentName: string | undefined;
selectedProviderId: string;
agentModelSelections: { [agentName: string]: { providerId: string; modelId: string } };
defaultProviders: { [key: string]: string };
}
interface ConfigStore {
activeDirectoryKey: string;
directoryScoped: Record<string, DirectoryScopedConfig>;
providers: ProviderWithModelList[];
agents: Agent[];
currentProviderId: string;
@@ -290,8 +352,10 @@ interface ConfigStore {
settingsDefaultModel: string | undefined; // format: "provider/model"
settingsDefaultAgent: string | undefined;
loadProviders: () => Promise<void>;
loadAgents: () => Promise<boolean>;
activateDirectory: (directory: string | null | undefined) => Promise<void>;
loadProviders: (options?: { directory?: string | null }) => Promise<void>;
loadAgents: (options?: { directory?: string | null }) => Promise<boolean>;
setProvider: (providerId: string) => void;
setModel: (modelId: string) => void;
setAgent: (agentName: string | undefined) => void;
@@ -322,6 +386,9 @@ export const useConfigStore = create<ConfigStore>()(
persist(
(set, get) => ({
activeDirectoryKey: resolveInitialDirectoryKey(),
directoryScoped: {},
providers: [],
agents: [],
currentProviderId: "",
@@ -336,15 +403,63 @@ export const useConfigStore = create<ConfigStore>()(
settingsDefaultModel: undefined,
settingsDefaultAgent: undefined,
loadProviders: async () => {
const previousProviders = get().providers;
const previousDefaults = get().defaultProviders;
activateDirectory: async (directory) => {
const directoryKey = toDirectoryKey(directory);
set((state) => {
const snapshot = state.directoryScoped[directoryKey];
if (snapshot) {
return {
activeDirectoryKey: directoryKey,
providers: snapshot.providers,
agents: snapshot.agents,
currentProviderId: snapshot.currentProviderId,
currentModelId: snapshot.currentModelId,
currentAgentName: snapshot.currentAgentName,
selectedProviderId: snapshot.selectedProviderId,
agentModelSelections: snapshot.agentModelSelections,
defaultProviders: snapshot.defaultProviders,
};
}
return {
activeDirectoryKey: directoryKey,
providers: [],
agents: [],
currentProviderId: "",
currentModelId: "",
currentAgentName: undefined,
selectedProviderId: "",
agentModelSelections: {},
defaultProviders: {},
};
});
if (!get().isConnected) {
return;
}
await get().loadProviders({ directory: fromDirectoryKey(directoryKey) });
await get().loadAgents({ directory: fromDirectoryKey(directoryKey) });
},
loadProviders: async (options) => {
const directoryKey = toDirectoryKey(options?.directory ?? fromDirectoryKey(get().activeDirectoryKey));
const existingSnapshot = get().directoryScoped[directoryKey];
const previousProviders = existingSnapshot?.providers ?? (get().activeDirectoryKey === directoryKey ? get().providers : []);
const previousDefaults = existingSnapshot?.defaultProviders ?? (get().activeDirectoryKey === directoryKey ? get().defaultProviders : {});
let lastError: unknown = null;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const metadataPromise = fetchModelsDevMetadata();
const apiResult = await opencodeClient.getProviders();
ensureModelsMetadataFetch(
() => get().modelsMetadata,
(metadata) => set({ modelsMetadata: metadata }),
);
const apiResult = await opencodeClient.withDirectory(
fromDirectoryKey(directoryKey),
() => opencodeClient.getProviders()
);
const providers = Array.isArray(apiResult?.providers) ? apiResult.providers : [];
const defaults = apiResult?.default || {};
@@ -357,16 +472,39 @@ export const useConfigStore = create<ConfigStore>()(
};
});
// Only store providers and defaults - model/agent selection handled in loadAgents
set({
providers: processedProviders,
defaultProviders: defaults,
set((state) => {
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
providers: [],
agents: [],
currentProviderId: "",
currentModelId: "",
currentAgentName: undefined,
selectedProviderId: "",
agentModelSelections: {},
defaultProviders: {},
};
const nextSnapshot: DirectoryScopedConfig = {
...baseSnapshot,
providers: processedProviders,
defaultProviders: defaults,
};
const nextState: Partial<ConfigStore> = {
directoryScoped: {
...state.directoryScoped,
[directoryKey]: nextSnapshot,
},
};
if (state.activeDirectoryKey === directoryKey) {
nextState.providers = processedProviders;
nextState.defaultProviders = defaults;
}
return nextState;
});
const metadata = await metadataPromise;
if (metadata.size > 0) {
set({ modelsMetadata: metadata });
}
return;
} catch (error) {
lastError = error;
@@ -376,10 +514,38 @@ export const useConfigStore = create<ConfigStore>()(
}
console.error("Failed to load providers:", lastError);
// Preserve previous state on failure instead of clearing it
set({
providers: previousProviders,
defaultProviders: previousDefaults,
set((state) => {
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
providers: [],
agents: [],
currentProviderId: "",
currentModelId: "",
currentAgentName: undefined,
selectedProviderId: "",
agentModelSelections: {},
defaultProviders: {},
};
const nextSnapshot: DirectoryScopedConfig = {
...baseSnapshot,
providers: previousProviders,
defaultProviders: previousDefaults,
};
const nextState: Partial<ConfigStore> = {
directoryScoped: {
...state.directoryScoped,
[directoryKey]: nextSnapshot,
},
};
if (state.activeDirectoryKey === directoryKey) {
nextState.providers = previousProviders;
nextState.defaultProviders = previousDefaults;
}
return nextState;
});
},
@@ -387,34 +553,135 @@ export const useConfigStore = create<ConfigStore>()(
const { providers } = get();
const provider = providers.find((p) => p.id === providerId);
if (provider) {
if (!provider) {
return;
}
const firstModel = provider.models[0];
const newModelId = firstModel?.id || "";
const firstModel = provider.models[0];
const newModelId = firstModel?.id || "";
set({
set((state) => {
const directoryKey = state.activeDirectoryKey;
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
providers: state.providers,
agents: state.agents,
currentProviderId: state.currentProviderId,
currentModelId: state.currentModelId,
currentAgentName: state.currentAgentName,
selectedProviderId: state.selectedProviderId,
agentModelSelections: state.agentModelSelections,
defaultProviders: state.defaultProviders,
};
const nextSnapshot: DirectoryScopedConfig = {
...baseSnapshot,
currentProviderId: providerId,
currentModelId: newModelId,
selectedProviderId: providerId,
});
}
};
return {
currentProviderId: providerId,
currentModelId: newModelId,
selectedProviderId: providerId,
directoryScoped: {
...state.directoryScoped,
[directoryKey]: nextSnapshot,
},
};
});
},
setModel: (modelId: string) => {
set({ currentModelId: modelId });
set((state) => {
const directoryKey = state.activeDirectoryKey;
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
providers: state.providers,
agents: state.agents,
currentProviderId: state.currentProviderId,
currentModelId: state.currentModelId,
currentAgentName: state.currentAgentName,
selectedProviderId: state.selectedProviderId,
agentModelSelections: state.agentModelSelections,
defaultProviders: state.defaultProviders,
};
const nextSnapshot: DirectoryScopedConfig = {
...baseSnapshot,
currentModelId: modelId,
};
return {
currentModelId: modelId,
directoryScoped: {
...state.directoryScoped,
[directoryKey]: nextSnapshot,
},
};
});
},
setSelectedProvider: (providerId: string) => {
set({ selectedProviderId: providerId });
set((state) => {
const directoryKey = state.activeDirectoryKey;
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
providers: state.providers,
agents: state.agents,
currentProviderId: state.currentProviderId,
currentModelId: state.currentModelId,
currentAgentName: state.currentAgentName,
selectedProviderId: state.selectedProviderId,
agentModelSelections: state.agentModelSelections,
defaultProviders: state.defaultProviders,
};
const nextSnapshot: DirectoryScopedConfig = {
...baseSnapshot,
selectedProviderId: providerId,
};
return {
selectedProviderId: providerId,
directoryScoped: {
...state.directoryScoped,
[directoryKey]: nextSnapshot,
},
};
});
},
saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => {
set((state) => ({
agentModelSelections: {
set((state) => {
const directoryKey = state.activeDirectoryKey;
const nextSelections = {
...state.agentModelSelections,
[agentName]: { providerId, modelId },
},
}));
};
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
providers: state.providers,
agents: state.agents,
currentProviderId: state.currentProviderId,
currentModelId: state.currentModelId,
currentAgentName: state.currentAgentName,
selectedProviderId: state.selectedProviderId,
agentModelSelections: state.agentModelSelections,
defaultProviders: state.defaultProviders,
};
const nextSnapshot: DirectoryScopedConfig = {
...baseSnapshot,
agentModelSelections: nextSelections,
};
return {
agentModelSelections: nextSelections,
directoryScoped: {
...state.directoryScoped,
[directoryKey]: nextSnapshot,
},
};
});
},
getAgentModelSelection: (agentName: string) => {
@@ -422,40 +689,97 @@ export const useConfigStore = create<ConfigStore>()(
return agentModelSelections[agentName] || null;
},
loadAgents: async () => {
const previousAgents = get().agents;
loadAgents: async (options) => {
const directoryKey = toDirectoryKey(options?.directory ?? fromDirectoryKey(get().activeDirectoryKey));
const existingSnapshot = get().directoryScoped[directoryKey];
const previousAgents = existingSnapshot?.agents ?? (get().activeDirectoryKey === directoryKey ? get().agents : []);
let lastError: unknown = null;
for (let attempt = 0; attempt < 3; attempt++) {
try {
// Fetch agents and OpenChamber settings in parallel
const [agents, openChamberDefaults] = await Promise.all([
opencodeClient.listAgents(),
opencodeClient.withDirectory(fromDirectoryKey(directoryKey), () => opencodeClient.listAgents()),
fetchOpenChamberDefaults(),
]);
const safeAgents = Array.isArray(agents) ? agents : [];
set({
agents: safeAgents,
// Store settings defaults so setAgent can respect them
settingsDefaultModel: openChamberDefaults.defaultModel,
settingsDefaultAgent: openChamberDefaults.defaultAgent,
const providers = get().activeDirectoryKey === directoryKey
? get().providers
: (get().directoryScoped[directoryKey]?.providers ?? []);
set((state) => {
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
providers,
agents: previousAgents,
currentProviderId: "",
currentModelId: "",
currentAgentName: undefined,
selectedProviderId: "",
agentModelSelections: {},
defaultProviders: {},
};
const nextSnapshot: DirectoryScopedConfig = {
...baseSnapshot,
providers,
agents: safeAgents,
};
const nextState: Partial<ConfigStore> = {
settingsDefaultModel: openChamberDefaults.defaultModel,
settingsDefaultAgent: openChamberDefaults.defaultAgent,
directoryScoped: {
...state.directoryScoped,
[directoryKey]: nextSnapshot,
},
};
if (state.activeDirectoryKey === directoryKey) {
nextState.agents = safeAgents;
}
return nextState;
});
const { providers } = get();
if (safeAgents.length === 0) {
set({ currentAgentName: undefined });
set((state) => {
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
providers,
agents: [],
currentProviderId: "",
currentModelId: "",
currentAgentName: undefined,
selectedProviderId: "",
agentModelSelections: {},
defaultProviders: {},
};
const nextSnapshot: DirectoryScopedConfig = {
...baseSnapshot,
providers,
agents: [],
currentAgentName: undefined,
};
const nextState: Partial<ConfigStore> = {
directoryScoped: {
...state.directoryScoped,
[directoryKey]: nextSnapshot,
},
};
if (state.activeDirectoryKey === directoryKey) {
nextState.currentAgentName = undefined;
}
return nextState;
});
return true;
}
// --- Agent Selection ---
// Priority: settings.defaultAgent → build → first primary → first agent
const primaryAgents = safeAgents.filter((agent) => isPrimaryMode(agent.mode));
const buildAgent = primaryAgents.find((agent) => agent.name === "build");
const fallbackAgent = buildAgent || primaryAgents[0] || safeAgents[0];
let resolvedAgent: Agent | undefined;
// Helper to validate model exists in providers
const validateModel = (providerId: string, modelId: string): boolean => {
const provider = providers.find((p) => p.id === providerId);
@@ -463,6 +787,14 @@ export const useConfigStore = create<ConfigStore>()(
return provider.models.some((m) => m.id === modelId);
};
// --- Agent Selection ---
// Priority: settings.defaultAgent → build → first primary → first agent
const primaryAgents = safeAgents.filter((agent) => isPrimaryMode(agent.mode));
const buildAgent = primaryAgents.find((agent) => agent.name === "build");
const fallbackAgent = buildAgent || primaryAgents[0] || safeAgents[0];
let resolvedAgent: Agent = fallbackAgent;
// Track invalid settings to clear
const invalidSettings: { defaultModel?: string; defaultAgent?: string } = {};
@@ -477,13 +809,6 @@ export const useConfigStore = create<ConfigStore>()(
}
}
// 2. Fall back to default logic
if (!resolvedAgent) {
resolvedAgent = fallbackAgent;
}
set({ currentAgentName: resolvedAgent.name });
// --- Model Selection ---
// Priority: settings.defaultModel → agent's preferred model → opencode/big-pickle
let resolvedProviderId: string | undefined;
@@ -524,12 +849,44 @@ export const useConfigStore = create<ConfigStore>()(
}
}
if (resolvedProviderId && resolvedModelId) {
set({
currentProviderId: resolvedProviderId,
currentModelId: resolvedModelId,
});
}
set((state) => {
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
providers,
agents: safeAgents,
currentProviderId: "",
currentModelId: "",
currentAgentName: undefined,
selectedProviderId: "",
agentModelSelections: {},
defaultProviders: {},
};
const nextSnapshot: DirectoryScopedConfig = {
...baseSnapshot,
providers,
agents: safeAgents,
currentAgentName: resolvedAgent.name,
currentProviderId: resolvedProviderId ?? baseSnapshot.currentProviderId,
currentModelId: resolvedModelId ?? baseSnapshot.currentModelId,
};
const nextState: Partial<ConfigStore> = {
directoryScoped: {
...state.directoryScoped,
[directoryKey]: nextSnapshot,
},
};
if (state.activeDirectoryKey === directoryKey) {
nextState.currentAgentName = resolvedAgent.name;
if (resolvedProviderId && resolvedModelId) {
nextState.currentProviderId = resolvedProviderId;
nextState.currentModelId = resolvedModelId;
}
}
return nextState;
});
// Clear invalid settings from storage (best-effort cleanup)
if (Object.keys(invalidSettings).length > 0) {
@@ -552,14 +909,75 @@ export const useConfigStore = create<ConfigStore>()(
}
console.error("Failed to load agents:", lastError);
set({ agents: previousAgents });
set((state) => {
const providers = state.activeDirectoryKey === directoryKey
? state.providers
: (state.directoryScoped[directoryKey]?.providers ?? []);
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
providers,
agents: [],
currentProviderId: "",
currentModelId: "",
currentAgentName: undefined,
selectedProviderId: "",
agentModelSelections: {},
defaultProviders: {},
};
const nextSnapshot: DirectoryScopedConfig = {
...baseSnapshot,
providers,
agents: previousAgents,
};
const nextState: Partial<ConfigStore> = {
directoryScoped: {
...state.directoryScoped,
[directoryKey]: nextSnapshot,
},
};
if (state.activeDirectoryKey === directoryKey) {
nextState.agents = previousAgents;
}
return nextState;
});
return false;
},
setAgent: (agentName: string | undefined) => {
const { agents, providers, settingsDefaultModel } = get();
set({ currentAgentName: agentName });
set((state) => {
const directoryKey = state.activeDirectoryKey;
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
providers: state.providers,
agents: state.agents,
currentProviderId: state.currentProviderId,
currentModelId: state.currentModelId,
currentAgentName: state.currentAgentName,
selectedProviderId: state.selectedProviderId,
agentModelSelections: state.agentModelSelections,
defaultProviders: state.defaultProviders,
};
const nextSnapshot: DirectoryScopedConfig = {
...baseSnapshot,
currentAgentName: agentName,
};
return {
currentAgentName: agentName,
directoryScoped: {
...state.directoryScoped,
[directoryKey]: nextSnapshot,
},
};
});
if (agentName && typeof window !== "undefined") {
@@ -608,9 +1026,33 @@ export const useConfigStore = create<ConfigStore>()(
if (parsed) {
const settingsProvider = providers.find((p) => p.id === parsed.providerId);
if (settingsProvider?.models.some((m) => m.id === parsed.modelId)) {
set({
currentProviderId: parsed.providerId,
currentModelId: parsed.modelId,
set((state) => {
const directoryKey = state.activeDirectoryKey;
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
providers: state.providers,
agents: state.agents,
currentProviderId: state.currentProviderId,
currentModelId: state.currentModelId,
currentAgentName: state.currentAgentName,
selectedProviderId: state.selectedProviderId,
agentModelSelections: state.agentModelSelections,
defaultProviders: state.defaultProviders,
};
const nextSnapshot: DirectoryScopedConfig = {
...baseSnapshot,
currentProviderId: parsed.providerId,
currentModelId: parsed.modelId,
};
return {
currentProviderId: parsed.providerId,
currentModelId: parsed.modelId,
directoryScoped: {
...state.directoryScoped,
[directoryKey]: nextSnapshot,
},
};
});
return;
}
@@ -625,10 +1067,35 @@ export const useConfigStore = create<ConfigStore>()(
const agentModel = agentProvider.models.find((model) => model.id === agent.model!.modelID);
if (agentModel) {
set({
currentProviderId: agent.model!.providerID,
currentModelId: agent.model!.modelID,
selectedProviderId: agent.model!.providerID,
set((state) => {
const directoryKey = state.activeDirectoryKey;
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
providers: state.providers,
agents: state.agents,
currentProviderId: state.currentProviderId,
currentModelId: state.currentModelId,
currentAgentName: state.currentAgentName,
selectedProviderId: state.selectedProviderId,
agentModelSelections: state.agentModelSelections,
defaultProviders: state.defaultProviders,
};
const nextSnapshot: DirectoryScopedConfig = {
...baseSnapshot,
currentProviderId: agent.model!.providerID,
currentModelId: agent.model!.modelID,
selectedProviderId: agent.model!.providerID,
};
return {
currentProviderId: agent.model!.providerID,
currentModelId: agent.model!.modelID,
selectedProviderId: agent.model!.providerID,
directoryScoped: {
...state.directoryScoped,
[directoryKey]: nextSnapshot,
},
};
});
}
}
@@ -671,28 +1138,29 @@ export const useConfigStore = create<ConfigStore>()(
initializeApp: async () => {
try {
console.log("Starting app initialization...");
const debug = streamDebugEnabled();
if (debug) console.log("Starting app initialization...");
const isConnected = await get().checkConnection();
console.log("Connection check result:", isConnected);
if (debug) console.log("Connection check result:", isConnected);
if (!isConnected) {
console.log("Server not connected");
if (debug) console.log("Server not connected");
set({ isConnected: false });
return;
}
console.log("Initializing app...");
if (debug) console.log("Initializing app...");
await opencodeClient.initApp();
console.log("Loading providers...");
if (debug) console.log("Loading providers...");
await get().loadProviders();
console.log("Loading agents...");
if (debug) console.log("Loading agents...");
await get().loadAgents();
set({ isInitialized: true, isConnected: true });
console.log("App initialized successfully");
if (debug) console.log("App initialized successfully");
} catch (error) {
console.error("Failed to initialize app:", error);
set({ isInitialized: false, isConnected: false });
@@ -770,3 +1238,17 @@ if (!unsubscribeConfigStoreChanges) {
}
});
}
let unsubscribeConfigStoreDirectoryChanges: (() => void) | null = null;
if (typeof window !== "undefined" && !unsubscribeConfigStoreDirectoryChanges) {
unsubscribeConfigStoreDirectoryChanges = useDirectoryStore.subscribe((state, prevState) => {
const nextKey = toDirectoryKey(state.currentDirectory);
const prevKey = toDirectoryKey(prevState.currentDirectory);
if (nextKey === prevKey) {
return;
}
void useConfigStore.getState().activateDirectory(state.currentDirectory);
});
}
+6 -151
View File
@@ -1,15 +1,9 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { opencodeClient } from '@/lib/opencode/client';
import type { DirectorySwitchResult } from '@/lib/opencode/client';
import { getDesktopHomeDirectory } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence';
import { startConfigUpdate, finishConfigUpdate, updateConfigUpdateMessage } from '@/lib/configUpdate';
import { useSessionStore } from '@/stores/useSessionStore';
import { refreshAfterOpenCodeRestart } from '@/stores/useAgentsStore';
import { useCommandsStore } from '@/stores/useCommandsStore';
import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { emitConfigChange } from '@/lib/configSync';
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
import { getSafeStorage } from './utils/safeStorage';
@@ -37,89 +31,6 @@ const persistedLastDirectory = safeStorage.getItem('lastDirectory');
const initialHasPersistedDirectory =
typeof persistedLastDirectory === 'string' && persistedLastDirectory.length > 0;
const notifyOpenCodeWorkingDirectory = (path: string, options?: { showOverlay?: boolean }) => {
const showOverlay = options?.showOverlay ?? true;
if (showOverlay) {
startConfigUpdate('Switching project directory…');
}
return opencodeClient.setOpenCodeWorkingDirectory(path).catch((error) => {
console.warn('Failed to synchronize OpenCode working directory:', error);
throw error;
});
};
const scheduleDirectoryFollowUp = (
restartPromise: Promise<DirectorySwitchResult | null>,
options: { showOverlay: boolean },
onComplete?: (result: DirectorySwitchResult | null) => void
) => {
const { showOverlay } = options;
const reloadSessions = () => {
try {
useSessionStore.getState().loadSessions();
} catch (err) {
console.error('Failed to reload sessions after directory change:', err);
}
};
void (async () => {
let result: DirectorySwitchResult | null = null;
try {
result = await restartPromise;
} catch (error) {
console.error('Failed to update OpenCode working directory:', error);
if (showOverlay) {
updateConfigUpdateMessage('Failed to switch directory. Please try again.');
await new Promise((resolve) => setTimeout(resolve, 1500));
finishConfigUpdate();
}
onComplete?.(result);
reloadSessions();
return;
}
try {
if (result && result.restarted) {
try {
if (typeof window !== 'undefined' && window.localStorage) {
window.localStorage.removeItem('commands-store');
}
} catch (storageError) {
console.warn('Failed to reset commands-store cache:', storageError);
}
await refreshAfterOpenCodeRestart({ message: 'Refreshing OpenCode configuration…' });
try {
await useCommandsStore.getState().loadCommands();
try {
emitConfigChange('commands', { source: 'useCommandsStore' });
} catch (syncError) {
console.warn('Failed to emit command configuration change:', syncError);
}
} catch (commandError) {
console.warn('Failed to reload commands after directory change:', commandError);
}
} else if (showOverlay) {
finishConfigUpdate();
}
} catch (error) {
console.error('Failed to refresh configuration after directory change:', error);
if (showOverlay) {
updateConfigUpdateMessage('Failed to refresh configuration. Please reload manually.');
await new Promise((resolve) => setTimeout(resolve, 1500));
finishConfigUpdate();
}
} finally {
onComplete?.(result);
reloadSessions();
}
})();
};
const invalidateFileSearchCache = (scope?: string | null) => {
try {
@@ -295,26 +206,20 @@ export const useDirectoryStore = create<DirectoryStore>()(
isSwitchingDirectory: false,
setDirectory: (path: string, options?: { showOverlay?: boolean }) => {
void options;
const homeDir = cachedHomeDirectory || get().homeDirectory || safeStorage.getItem('homeDirectory');
const resolvedPath = resolveDirectoryPath(path, homeDir);
if (streamDebugEnabled()) {
console.log('[DirectoryStore] setDirectory called with path:', resolvedPath);
}
const showOverlay = options?.showOverlay ?? true;
opencodeClient.setDirectory(resolvedPath);
invalidateFileSearchCache();
const restartPromise = notifyOpenCodeWorkingDirectory(resolvedPath, { showOverlay });
if (streamDebugEnabled()) {
console.log('[DirectoryStore] notifyOpenCodeWorkingDirectory initiated');
}
set((state) => {
const newHistory = [...state.directoryHistory.slice(0, state.historyIndex + 1), resolvedPath];
safeStorage.setItem('lastDirectory', resolvedPath);
void updateDesktopSettings({ lastDirectory: resolvedPath });
return {
@@ -323,21 +228,9 @@ export const useDirectoryStore = create<DirectoryStore>()(
historyIndex: newHistory.length - 1,
hasPersistedDirectory: true,
isHomeReady: true,
isSwitchingDirectory: true,
isSwitchingDirectory: false,
};
});
scheduleDirectoryFollowUp(restartPromise, { showOverlay }, () => {
set((state) => {
if (state.currentDirectory !== resolvedPath) {
return {};
}
if (!state.isSwitchingDirectory) {
return {};
}
return { isSwitchingDirectory: false };
});
});
},
goBack: () => {
@@ -348,7 +241,6 @@ export const useDirectoryStore = create<DirectoryStore>()(
opencodeClient.setDirectory(newDirectory);
invalidateFileSearchCache();
const restartPromise = notifyOpenCodeWorkingDirectory(newDirectory);
safeStorage.setItem('lastDirectory', newDirectory);
@@ -359,19 +251,7 @@ export const useDirectoryStore = create<DirectoryStore>()(
historyIndex: newIndex,
hasPersistedDirectory: true,
isHomeReady: true,
isSwitchingDirectory: true,
});
scheduleDirectoryFollowUp(restartPromise, { showOverlay: true }, () => {
set((state) => {
if (state.currentDirectory !== newDirectory) {
return {};
}
if (!state.isSwitchingDirectory) {
return {};
}
return { isSwitchingDirectory: false };
});
isSwitchingDirectory: false,
});
}
},
@@ -384,7 +264,6 @@ export const useDirectoryStore = create<DirectoryStore>()(
opencodeClient.setDirectory(newDirectory);
invalidateFileSearchCache();
const restartPromise = notifyOpenCodeWorkingDirectory(newDirectory);
safeStorage.setItem('lastDirectory', newDirectory);
@@ -395,19 +274,7 @@ export const useDirectoryStore = create<DirectoryStore>()(
historyIndex: newIndex,
hasPersistedDirectory: true,
isHomeReady: true,
isSwitchingDirectory: true,
});
scheduleDirectoryFollowUp(restartPromise, { showOverlay: true }, () => {
set((state) => {
if (state.currentDirectory !== newDirectory) {
return {};
}
if (!state.isSwitchingDirectory) {
return {};
}
return { isSwitchingDirectory: false };
});
isSwitchingDirectory: false,
});
}
},
@@ -484,12 +351,12 @@ export const useDirectoryStore = create<DirectoryStore>()(
updates.currentDirectory = resolvedHome;
updates.directoryHistory = [resolvedHome];
updates.historyIndex = 0;
updates.isSwitchingDirectory = true;
updates.isSwitchingDirectory = false;
} else if (currentChanged || historyChanged) {
updates.currentDirectory = resolvedCurrent as string;
updates.directoryHistory = resolvedHistory;
updates.historyIndex = Math.min(state.historyIndex, resolvedHistory.length - 1);
updates.isSwitchingDirectory = true;
updates.isSwitchingDirectory = false;
}
set(() => updates as Partial<DirectoryStore>);
@@ -501,18 +368,6 @@ export const useDirectoryStore = create<DirectoryStore>()(
safeStorage.setItem('lastDirectory', nextDirectory);
void updateDesktopSettings({ lastDirectory: nextDirectory });
const restartPromise = notifyOpenCodeWorkingDirectory(nextDirectory, { showOverlay: false });
scheduleDirectoryFollowUp(restartPromise, { showOverlay: false }, () => {
set((state) => {
if (state.currentDirectory !== nextDirectory) {
return {};
}
if (!state.isSwitchingDirectory) {
return {};
}
return { isSwitchingDirectory: false };
});
});
}
void updateDesktopSettings({ homeDirectory: resolvedHome });
+30 -4
View File
@@ -6,6 +6,7 @@ import { createWorktree } from '@/lib/git/worktreeService';
import { checkIsGitRepository } from '@/lib/gitApi';
import { useSessionStore } from './sessionStore';
import { useDirectoryStore } from './useDirectoryStore';
import { useProjectsStore } from './useProjectsStore';
/**
* Generate a git-safe slug from a string.
@@ -48,8 +49,33 @@ const sanitizeWorktreeSlug = (value: string): string => {
};
const getCurrentDirectory = (): string | null => {
return useDirectoryStore.getState().currentDirectory ?? null;
const resolveProjectDirectory = (): string | null => {
const projectsState = useProjectsStore.getState();
const activeProjectId = projectsState.activeProjectId;
const activeProjectPath = activeProjectId
? projectsState.projects.find((project) => project.id === activeProjectId)?.path
: undefined;
if (typeof activeProjectPath === 'string' && activeProjectPath.trim().length > 0) {
return activeProjectPath;
}
const currentDirectory = useDirectoryStore.getState().currentDirectory ?? null;
if (!currentDirectory) {
return null;
}
const normalized = currentDirectory.replace(/\\/g, '/').replace(/\/+$/, '') || currentDirectory;
const marker = '/.openchamber/';
const markerIndex = normalized.indexOf(marker);
if (markerIndex > 0) {
return normalized.slice(0, markerIndex);
}
if (normalized.endsWith('/.openchamber')) {
return normalized.slice(0, normalized.length - '/.openchamber'.length);
}
return normalized;
};
interface MultiRunState {
@@ -99,7 +125,7 @@ export const useMultiRunStore = create<MultiRunStore>()(
set({ isLoading: true, error: null });
try {
const directory = getCurrentDirectory();
const directory = resolveProjectDirectory();
if (!directory) {
set({ error: 'No directory selected', isLoading: false });
return null;
@@ -236,7 +262,7 @@ export const useMultiRunStore = create<MultiRunStore>()(
})();
set({ isLoading: false });
return { sessionIds, firstSessionId };
return { groupSlug, sessionIds, firstSessionId };
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to create Multi-Run',
+447
View File
@@ -0,0 +1,447 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { opencodeClient } from '@/lib/opencode/client';
import type { ProjectEntry } from '@/lib/api/types';
import type { DesktopSettings } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence';
import { getSafeStorage } from './utils/safeStorage';
import { useDirectoryStore } from './useDirectoryStore';
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
interface ProjectPathValidationResult {
ok: boolean;
normalizedPath?: string;
reason?: string;
}
interface ProjectsStore {
projects: ProjectEntry[];
activeProjectId: string | null;
addProject: (path: string, options?: { label?: string; id?: string }) => ProjectEntry | null;
removeProject: (id: string) => void;
setActiveProject: (id: string) => void;
setActiveProjectIdOnly: (id: string) => void;
renameProject: (id: string, label: string) => void;
reorderProjects: (fromIndex: number, toIndex: number) => void;
validateProjectPath: (path: string) => ProjectPathValidationResult;
synchronizeFromSettings: (settings: DesktopSettings) => void;
getActiveProject: () => ProjectEntry | null;
}
const safeStorage = getSafeStorage();
const PROJECTS_STORAGE_KEY = 'projects';
const ACTIVE_PROJECT_STORAGE_KEY = 'activeProjectId';
const resolveTildePath = (value: string, homeDir?: string | null): string => {
const trimmed = value.trim();
if (!trimmed.startsWith('~')) {
return trimmed;
}
if (!homeDir) {
return trimmed;
}
if (trimmed === '~') {
return homeDir;
}
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
return `${homeDir}${trimmed.slice(1)}`;
}
return trimmed;
};
const normalizeProjectPath = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) {
return '';
}
const homeDirectory = safeStorage.getItem('homeDirectory') || useDirectoryStore.getState().homeDirectory || '';
const expanded = resolveTildePath(trimmed, homeDirectory);
const normalized = expanded.replace(/\\/g, '/');
if (normalized === '/') {
return '/';
}
return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized;
};
const deriveProjectLabel = (path: string): string => {
const normalized = normalizeProjectPath(path);
if (!normalized || normalized === '/') {
return 'Root';
}
const segments = normalized.split('/').filter(Boolean);
return segments[segments.length - 1] || normalized;
};
const createProjectId = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `proj_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
};
const sanitizeProjects = (value: unknown): ProjectEntry[] => {
if (!Array.isArray(value)) {
return [];
}
const result: ProjectEntry[] = [];
const seenIds = new Set<string>();
const seenPaths = new Set<string>();
for (const entry of value) {
if (!entry || typeof entry !== 'object') continue;
const candidate = entry as Record<string, unknown>;
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : '';
if (!id || !rawPath) continue;
const normalizedPath = normalizeProjectPath(rawPath);
if (!normalizedPath) continue;
if (seenIds.has(id) || seenPaths.has(normalizedPath)) continue;
seenIds.add(id);
seenPaths.add(normalizedPath);
const project: ProjectEntry = {
id,
path: normalizedPath,
};
if (typeof candidate.label === 'string' && candidate.label.trim().length > 0) {
project.label = candidate.label.trim();
}
if (typeof candidate.addedAt === 'number' && Number.isFinite(candidate.addedAt) && candidate.addedAt >= 0) {
project.addedAt = candidate.addedAt;
}
if (typeof candidate.lastOpenedAt === 'number' && Number.isFinite(candidate.lastOpenedAt) && candidate.lastOpenedAt >= 0) {
project.lastOpenedAt = candidate.lastOpenedAt;
}
result.push(project);
}
return result;
};
const readPersistedProjects = (): ProjectEntry[] => {
try {
const raw = safeStorage.getItem(PROJECTS_STORAGE_KEY);
if (!raw) {
return [];
}
return sanitizeProjects(JSON.parse(raw));
} catch {
return [];
}
};
const readPersistedActiveProjectId = (): string | null => {
try {
const raw = safeStorage.getItem(ACTIVE_PROJECT_STORAGE_KEY);
if (typeof raw === 'string' && raw.trim().length > 0) {
return raw.trim();
}
} catch {
return null;
}
return null;
};
const cacheProjects = (projects: ProjectEntry[], activeProjectId: string | null) => {
try {
safeStorage.setItem(PROJECTS_STORAGE_KEY, JSON.stringify(projects));
} catch {
// ignored
}
try {
if (activeProjectId) {
safeStorage.setItem(ACTIVE_PROJECT_STORAGE_KEY, activeProjectId);
} else {
safeStorage.removeItem(ACTIVE_PROJECT_STORAGE_KEY);
}
} catch {
// ignored
}
};
const persistProjects = (projects: ProjectEntry[], activeProjectId: string | null) => {
cacheProjects(projects, activeProjectId);
void updateDesktopSettings({ projects, activeProjectId: activeProjectId ?? undefined });
};
const initialProjects = readPersistedProjects();
const getVSCodeWorkspaceProject = (): { projects: ProjectEntry[]; activeProjectId: string | null } | null => {
if (typeof window === 'undefined') {
return null;
}
const runtimeApis = (window as unknown as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isVSCode?: boolean } } })
.__OPENCHAMBER_RUNTIME_APIS__;
if (!runtimeApis?.runtime?.isVSCode) {
return null;
}
const workspaceFolder = (window as unknown as { __VSCODE_CONFIG__?: { workspaceFolder?: unknown } }).__VSCODE_CONFIG__?.workspaceFolder;
if (typeof workspaceFolder !== 'string' || workspaceFolder.trim().length === 0) {
return null;
}
const normalizedPath = normalizeProjectPath(workspaceFolder);
if (!normalizedPath) {
return null;
}
const id = `vscode:${normalizedPath}`;
const entry: ProjectEntry = {
id,
path: normalizedPath,
label: deriveProjectLabel(normalizedPath),
addedAt: Date.now(),
lastOpenedAt: Date.now(),
};
if (streamDebugEnabled()) {
console.log('[OpenChamber][VSCode][projects] Using workspace fallback project', entry);
}
return { projects: [entry], activeProjectId: id };
};
// VS Code runtime should behave as a single-project environment scoped to the workspace folder.
// Always prefer the workspace project over any persisted multi-project registry.
const vscodeWorkspace = getVSCodeWorkspaceProject();
const effectiveInitialProjects = vscodeWorkspace?.projects ?? initialProjects;
const initialActiveProjectId = vscodeWorkspace?.activeProjectId
?? readPersistedActiveProjectId()
?? effectiveInitialProjects[0]?.id
?? null;
if (vscodeWorkspace) {
cacheProjects(effectiveInitialProjects, initialActiveProjectId);
}
export const useProjectsStore = create<ProjectsStore>()(
devtools((set, get) => ({
projects: effectiveInitialProjects,
activeProjectId: initialActiveProjectId,
validateProjectPath: (path: string): ProjectPathValidationResult => {
if (typeof path !== 'string' || path.trim().length === 0) {
return { ok: false, reason: 'Provide a directory path.' };
}
const normalized = normalizeProjectPath(path);
if (!normalized) {
return { ok: false, reason: 'Directory path cannot be empty.' };
}
return { ok: true, normalizedPath: normalized };
},
addProject: (path: string, options?: { label?: string; id?: string }) => {
if (vscodeWorkspace) {
return null;
}
const { validateProjectPath } = get();
const validation = validateProjectPath(path);
if (!validation.ok || !validation.normalizedPath) {
return null;
}
const normalizedPath = validation.normalizedPath;
const existing = get().projects.find((project) => project.path === normalizedPath);
if (existing) {
get().setActiveProject(existing.id);
return existing;
}
const now = Date.now();
const label = options?.label?.trim() || deriveProjectLabel(normalizedPath);
const candidateId = options?.id?.trim();
const id = candidateId && !get().projects.some((project) => project.id === candidateId)
? candidateId
: createProjectId();
const entry: ProjectEntry = {
id,
path: normalizedPath,
label,
addedAt: now,
lastOpenedAt: now,
};
const nextProjects = [...get().projects, entry];
set({ projects: nextProjects });
if (streamDebugEnabled()) {
console.info('[ProjectsStore] Added project', entry);
}
get().setActiveProject(entry.id);
return entry;
},
removeProject: (id: string) => {
if (vscodeWorkspace) {
return;
}
const current = get();
const nextProjects = current.projects.filter((project) => project.id !== id);
let nextActiveId = current.activeProjectId;
if (current.activeProjectId === id) {
nextActiveId = nextProjects[0]?.id ?? null;
}
set({ projects: nextProjects, activeProjectId: nextActiveId });
persistProjects(nextProjects, nextActiveId);
if (nextActiveId) {
const nextActive = nextProjects.find((project) => project.id === nextActiveId);
if (nextActive) {
opencodeClient.setDirectory(nextActive.path);
useDirectoryStore.getState().setDirectory(nextActive.path, { showOverlay: false });
}
} else {
void useDirectoryStore.getState().goHome();
}
},
setActiveProject: (id: string) => {
if (vscodeWorkspace) {
return;
}
const { projects, activeProjectId } = get();
if (activeProjectId === id) {
return;
}
const target = projects.find((project) => project.id === id);
if (!target) {
return;
}
const now = Date.now();
const nextProjects = projects.map((project) =>
project.id === id ? { ...project, lastOpenedAt: now } : project
);
set({ projects: nextProjects, activeProjectId: id });
persistProjects(nextProjects, id);
opencodeClient.setDirectory(target.path);
useDirectoryStore.getState().setDirectory(target.path, { showOverlay: false });
},
setActiveProjectIdOnly: (id: string) => {
if (vscodeWorkspace) {
return;
}
const { projects, activeProjectId } = get();
if (activeProjectId === id) {
return;
}
const target = projects.find((project) => project.id === id);
if (!target) {
return;
}
const now = Date.now();
const nextProjects = projects.map((project) =>
project.id === id ? { ...project, lastOpenedAt: now } : project
);
set({ projects: nextProjects, activeProjectId: id });
persistProjects(nextProjects, id);
},
renameProject: (id: string, label: string) => {
if (vscodeWorkspace) {
return;
}
const trimmed = label.trim();
if (!trimmed) {
return;
}
const { projects, activeProjectId } = get();
const nextProjects = projects.map((project) =>
project.id === id ? { ...project, label: trimmed } : project
);
set({ projects: nextProjects });
persistProjects(nextProjects, activeProjectId);
},
reorderProjects: (fromIndex: number, toIndex: number) => {
if (vscodeWorkspace) {
return;
}
const { projects, activeProjectId } = get();
if (
fromIndex < 0 ||
fromIndex >= projects.length ||
toIndex < 0 ||
toIndex >= projects.length ||
fromIndex === toIndex
) {
return;
}
const nextProjects = [...projects];
const [moved] = nextProjects.splice(fromIndex, 1);
nextProjects.splice(toIndex, 0, moved);
set({ projects: nextProjects });
persistProjects(nextProjects, activeProjectId);
},
synchronizeFromSettings: (settings: DesktopSettings) => {
if (vscodeWorkspace) {
return;
}
const incomingProjects = sanitizeProjects(settings.projects ?? []);
const incomingActive = typeof settings.activeProjectId === 'string' && settings.activeProjectId.trim()
? settings.activeProjectId.trim()
: null;
const current = get();
const projectsChanged = JSON.stringify(current.projects) !== JSON.stringify(incomingProjects);
const activeChanged = current.activeProjectId !== incomingActive;
if (!projectsChanged && !activeChanged) {
return;
}
set({ projects: incomingProjects, activeProjectId: incomingActive });
cacheProjects(incomingProjects, incomingActive);
if (incomingActive) {
const activeProject = incomingProjects.find((project) => project.id === incomingActive);
if (activeProject) {
opencodeClient.setDirectory(activeProject.path);
useDirectoryStore.getState().setDirectory(activeProject.path, { showOverlay: false });
}
}
},
getActiveProject: () => {
const { projects, activeProjectId } = get();
if (!activeProjectId) {
return null;
}
return projects.find((project) => project.id === activeProjectId) ?? null;
},
}), { name: 'projects-store' })
);
if (typeof window !== 'undefined') {
window.addEventListener('openchamber:settings-synced', (event: Event) => {
const detail = (event as CustomEvent<DesktopSettings>).detail;
if (detail && typeof detail === 'object') {
useProjectsStore.getState().synchronizeFromSettings(detail);
}
});
}
+16 -7
View File
@@ -2,7 +2,7 @@ import { create } from "zustand";
import type { StoreApi, UseBoundStore } from "zustand";
import { devtools } from "zustand/middleware";
import type { Session, Message, Part } from "@opencode-ai/sdk/v2";
import type { Permission, PermissionResponse } from "@/types/permission";
import type { PermissionRequest, PermissionResponse } from "@/types/permission";
import type { SessionStore, AttachedFile, EditPermissionMode } from "./types/sessionTypes";
import { ACTIVE_SESSION_WINDOW, MEMORY_LIMITS } from "./types/sessionTypes";
@@ -66,6 +66,7 @@ export const useSessionStore = create<SessionStore>()(
(set, get) => ({
sessions: [],
sessionsByDirectory: new Map(),
currentSessionId: null,
lastLoadedDirectory: null,
messages: new Map(),
@@ -87,6 +88,7 @@ export const useSessionStore = create<SessionStore>()(
webUICreatedSessions: new Set(),
worktreeMetadata: new Map(),
availableWorktrees: [],
availableWorktreesByProject: new Map(),
currentAgentContext: new Map(),
sessionContextUsage: new Map(),
sessionAgentEditModes: new Map(),
@@ -420,7 +422,7 @@ export const useSessionStore = create<SessionStore>()(
markMessageStreamSettled: (messageId: string) => useMessageStore.getState().markMessageStreamSettled(messageId),
updateMessageInfo: (sessionId: string, messageId: string, messageInfo: Record<string, unknown>) => useMessageStore.getState().updateMessageInfo(sessionId, messageId, messageInfo),
updateSessionCompaction: (sessionId: string, compactingTimestamp?: number | null) => useMessageStore.getState().updateSessionCompaction(sessionId, compactingTimestamp ?? null),
addPermission: (permission: Permission) => {
addPermission: (permission: PermissionRequest) => {
const contextData = {
currentAgentContext: useContextStore.getState().currentAgentContext,
sessionAgentSelections: useContextStore.getState().sessionAgentSelections,
@@ -428,9 +430,10 @@ export const useSessionStore = create<SessionStore>()(
};
return usePermissionStore.getState().addPermission(permission, contextData);
},
respondToPermission: (sessionId: string, permissionId: string, response: PermissionResponse) => usePermissionStore.getState().respondToPermission(sessionId, permissionId, response),
respondToPermission: (sessionId: string, requestId: string, response: PermissionResponse) => usePermissionStore.getState().respondToPermission(sessionId, requestId, response),
clearError: () => useSessionManagementStore.getState().clearError(),
getSessionsByDirectory: (directory: string) => useSessionManagementStore.getState().getSessionsByDirectory(directory),
getDirectoryForSession: (sessionId: string) => useSessionManagementStore.getState().getDirectoryForSession(sessionId),
getLastMessageModel: (sessionId: string) => useMessageStore.getState().getLastMessageModel(sessionId),
getCurrentAgent: (sessionId: string) => useContextStore.getState().getCurrentAgent(sessionId),
syncMessages: (sessionId: string, messages: { info: Message; parts: Part[] }[]) => useMessageStore.getState().syncMessages(sessionId, messages),
@@ -507,6 +510,7 @@ export const useSessionStore = create<SessionStore>()(
return useContextStore.getState().pollForTokenUpdates(sessionId, messageId, messages, maxAttempts);
},
updateSession: (session: Session) => useSessionManagementStore.getState().updateSession(session),
removeSessionFromStore: (sessionId: string) => useSessionManagementStore.getState().removeSessionFromStore(sessionId),
revertToMessage: async (sessionId: string, messageId: string) => {
// Get the message text before reverting
@@ -559,7 +563,7 @@ export const useSessionStore = create<SessionStore>()(
const sessions = get().sessions;
const currentSession = sessions.find(s => s.id === sessionId);
// Silent no-op like OpenCode CLI
// No-op when there is nothing to undo/redo
if (userMessages.length === 0) {
return;
}
@@ -576,7 +580,7 @@ export const useSessionStore = create<SessionStore>()(
targetMessage = userMessages[userMessages.length - 1];
}
// Silent no-op like OpenCode CLI
// No-op when there is nothing to undo/redo
if (!targetMessage) {
return;
}
@@ -598,7 +602,7 @@ export const useSessionStore = create<SessionStore>()(
const currentSession = sessions.find(s => s.id === sessionId);
const revertToId = currentSession?.revert?.messageID;
// Silent no-op like OpenCode CLI
// No-op when there is nothing to undo/redo
if (!revertToId) {
return;
}
@@ -708,13 +712,15 @@ useSessionManagementStore.subscribe((state, prevState) => {
if (
state.sessions === prevState.sessions &&
state.sessionsByDirectory === prevState.sessionsByDirectory &&
state.currentSessionId === prevState.currentSessionId &&
state.lastLoadedDirectory === prevState.lastLoadedDirectory &&
state.isLoading === prevState.isLoading &&
state.error === prevState.error &&
state.webUICreatedSessions === prevState.webUICreatedSessions &&
state.worktreeMetadata === prevState.worktreeMetadata &&
state.availableWorktrees === prevState.availableWorktrees
state.availableWorktrees === prevState.availableWorktrees &&
state.availableWorktreesByProject === prevState.availableWorktreesByProject
) {
return;
}
@@ -723,6 +729,7 @@ useSessionManagementStore.subscribe((state, prevState) => {
useSessionStore.setState({
sessions: state.sessions,
sessionsByDirectory: state.sessionsByDirectory,
currentSessionId: draftOpen ? null : state.currentSessionId,
lastLoadedDirectory: state.lastLoadedDirectory,
isLoading: state.isLoading,
@@ -730,6 +737,7 @@ useSessionManagementStore.subscribe((state, prevState) => {
webUICreatedSessions: state.webUICreatedSessions,
worktreeMetadata: state.worktreeMetadata,
availableWorktrees: state.availableWorktrees,
availableWorktreesByProject: state.availableWorktreesByProject,
});
});
@@ -873,6 +881,7 @@ useSessionStore.setState({
webUICreatedSessions: useSessionManagementStore.getState().webUICreatedSessions,
worktreeMetadata: useSessionManagementStore.getState().worktreeMetadata,
availableWorktrees: useSessionManagementStore.getState().availableWorktrees,
availableWorktreesByProject: useSessionManagementStore.getState().availableWorktreesByProject,
messages: useMessageStore.getState().messages,
sessionMemoryState: useMessageStore.getState().sessionMemoryState,
messageStreamStates: useMessageStore.getState().messageStreamStates,
@@ -13,8 +13,14 @@ import type {
} from '@/lib/api/types';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { opencodeClient } from '@/lib/opencode/client';
const getCurrentDirectory = (): string | null => {
const opencodeDirectory = opencodeClient.getDirectory();
if (typeof opencodeDirectory === 'string' && opencodeDirectory.trim().length > 0) {
return opencodeDirectory;
}
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const store = (window as any).__zustand_directory_store__;
@@ -24,6 +30,7 @@ const getCurrentDirectory = (): string | null => {
} catch {
// ignore
}
return null;
};
+8 -1
View File
@@ -8,8 +8,14 @@ import {
} from "@/lib/configUpdate";
import { getSafeStorage } from "./utils/safeStorage";
// Access directory store without circular dependency
import { opencodeClient } from '@/lib/opencode/client';
const getCurrentDirectory = (): string | null => {
const opencodeDirectory = opencodeClient.getDirectory();
if (typeof opencodeDirectory === 'string' && opencodeDirectory.trim().length > 0) {
return opencodeDirectory;
}
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const store = (window as any).__zustand_directory_store__;
@@ -19,6 +25,7 @@ const getCurrentDirectory = (): string | null => {
} catch {
// ignore
}
return null;
};
+5 -1
View File
@@ -2,6 +2,7 @@ import { create } from "zustand";
import { devtools } from "zustand/middleware";
import { opencodeClient } from "@/lib/opencode/client";
import { useSessionStore } from "./useSessionStore";
export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled";
export type TodoPriority = "high" | "medium" | "low";
@@ -46,7 +47,10 @@ export const useTodoStore = create<TodoStore>()(
set({ isLoading: true });
try {
const rawTodos = await opencodeClient.getSessionTodos(sessionId);
const directory = useSessionStore.getState().getDirectoryForSession(sessionId);
const rawTodos = directory
? await opencodeClient.withDirectory(directory, () => opencodeClient.getSessionTodos(sessionId))
: await opencodeClient.getSessionTodos(sessionId);
const todos = rawTodos.map(normalizeTodo);
set((state) => {
+52 -12
View File
@@ -15,14 +15,33 @@ export const isEditPermissionType = (type?: string | null): boolean => {
return EDIT_PERMISSION_TOOL_NAMES.has(type.toLowerCase());
};
const resolveConfigStore = () => {
type PermissionAction = 'allow' | 'deny' | 'ask';
type PermissionRule = {
permission: string;
pattern: string;
action: PermissionAction;
};
type ConfigStoreAgent = {
name: string;
permission?: PermissionRule[];
};
type ConfigStoreState = {
agents?: ConfigStoreAgent[];
};
type ConfigStoreRef = { getState?: () => ConfigStoreState };
const resolveConfigStore = (): ConfigStoreRef | undefined => {
if (typeof window === 'undefined') {
return undefined;
}
return (window as { __zustand_config_store__?: { getState?: () => { agents?: Array<{ name: string; permission?: { edit?: string }; tools?: { edit?: boolean } }> } } }).__zustand_config_store__;
return (window as { __zustand_config_store__?: ConfigStoreRef }).__zustand_config_store__;
};
const getAgentDefinition = (agentName?: string): { name: string; permission?: { edit?: string }; tools?: { edit?: boolean } } | undefined => {
const getAgentDefinition = (agentName?: string): ConfigStoreAgent | undefined => {
if (!agentName) {
return undefined;
}
@@ -31,24 +50,45 @@ const getAgentDefinition = (agentName?: string): { name: string; permission?: {
const configStore = resolveConfigStore();
if (configStore?.getState) {
const state = configStore.getState();
return state.agents?.find?.((agent: { name: string; permission?: { edit?: string }; tools?: { edit?: boolean } }) => agent.name === agentName);
return state.agents?.find?.((agent) => agent.name === agentName);
}
} catch { /* ignored */ }
} catch {
/* ignored */
}
return undefined;
};
const resolvePermissionAction = (ruleset: PermissionRule[] | undefined, permission: string): PermissionAction => {
if (!ruleset || ruleset.length === 0) {
return 'ask';
}
// Prefer explicit rule for the tool at wildcard pattern.
for (let index = ruleset.length - 1; index >= 0; index -= 1) {
const rule = ruleset[index];
if (rule.permission === permission && rule.pattern === '*') {
return rule.action;
}
}
// Fall back to global wildcard.
for (let index = ruleset.length - 1; index >= 0; index -= 1) {
const rule = ruleset[index];
if (rule.permission === '*' && rule.pattern === '*') {
return rule.action;
}
}
return 'ask';
};
export const getAgentDefaultEditPermission = (agentName?: string): EditPermissionMode => {
const agent = getAgentDefinition(agentName);
if (!agent) {
return 'ask';
}
const permission = agent.permission?.edit;
if (permission === 'allow' || permission === 'ask' || permission === 'deny' || permission === 'full') {
return permission;
}
const editToolEnabled = agent.tools ? agent.tools.edit !== false : true;
return editToolEnabled ? 'ask' : 'deny';
const action = resolvePermissionAction(agent.permission, 'edit');
return action;
};
+2
View File
@@ -36,6 +36,8 @@ export interface CreateMultiRunParams {
}
export interface CreateMultiRunResult {
/** Canonical group slug used in session titles */
groupSlug: string;
/** Session IDs created successfully (in selection order) */
sessionIds: string[];
/** First successfully created session ID, if any */
+17 -15
View File
@@ -1,26 +1,28 @@
export interface Permission {
export interface PermissionRequest {
id: string;
type: string;
pattern?: string | string[];
patterns?: string[]; // New system: array of specific patterns requesting approval
always?: string[]; // New system: what will be auto-approved on "always" click
sessionID: string;
messageID: string;
callID?: string;
title: string;
permission: string;
patterns: string[];
metadata: Record<string, unknown>;
time: {
created: number;
};
always: string[];
tool?: {
messageID: string;
callID: string;
};
}
export interface PermissionEvent {
type: 'permission.updated';
properties: Permission;
export type PermissionResponse = 'once' | 'always' | 'reject';
export interface PermissionAskedEvent {
type: 'permission.asked';
properties: PermissionRequest;
}
export type PermissionResponse = 'once' | 'always' | 'reject';
export interface PermissionRepliedEvent {
type: 'permission.replied';
properties: {
sessionID: string;
requestID: string;
reply: PermissionResponse;
};
}