fix(projects): open draft after adding project
This commit is contained in:
@@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Projects: new project directories can now be created outside the current workspace, and adding, creating, or cloning a project opens a new-session draft targeted at that project instead of leaving the previous session context active.
|
||||||
- **Observability panel:** a new panel near to the chat brings the active goal, tasks, subagents, pinned context, MCP servers, and context usage into one live view. The session list also shows how long an agent has been working.
|
- **Observability panel:** a new panel near to the chat brings the active goal, tasks, subagents, pinned context, MCP servers, and context usage into one live view. The session list also shows how long an agent has been working.
|
||||||
- **Scheduled Tasks:** projects can now define recurring tasks as Markdown files in `.agents/loops`; opening the task list discovers file changes without a restart, and loop tasks can be edited, enabled, disabled, deleted, or run from the app (thanks to @makeittech).
|
- **Scheduled Tasks:** projects can now define recurring tasks as Markdown files in `.agents/loops`; opening the task list discovers file changes without a restart, and loop tasks can be edited, enabled, disabled, deleted, or run from the app (thanks to @makeittech).
|
||||||
- **Settings:** OpenCode configuration changes now accumulate behind a single Apply & Restart action instead of restarting OpenCode after every edit; the confirmation warns when active chats will be stopped (thanks to @makeittech).
|
- **Settings:** OpenCode configuration changes now accumulate behind a single Apply & Restart action instead of restarting OpenCode after every edit; the confirmation warns when active chats will be stopped (thanks to @makeittech).
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
||||||
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
|
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
@@ -146,6 +148,9 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
const homeDirectory = useDirectoryStore((s) => s.homeDirectory);
|
const homeDirectory = useDirectoryStore((s) => s.homeDirectory);
|
||||||
const projects = useProjectsStore((s) => s.projects);
|
const projects = useProjectsStore((s) => s.projects);
|
||||||
const addProject = useProjectsStore((s) => s.addProject);
|
const addProject = useProjectsStore((s) => s.addProject);
|
||||||
|
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
|
||||||
|
const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen);
|
||||||
|
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
|
||||||
const gitIdentityProfiles = useGitIdentitiesStore((s) => s.profiles);
|
const gitIdentityProfiles = useGitIdentitiesStore((s) => s.profiles);
|
||||||
const globalGitIdentity = useGitIdentitiesStore((s) => s.globalIdentity);
|
const globalGitIdentity = useGitIdentitiesStore((s) => s.globalIdentity);
|
||||||
const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId);
|
const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId);
|
||||||
@@ -405,17 +410,26 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}, [onOpenChange]);
|
}, [onOpenChange]);
|
||||||
|
|
||||||
|
const openProjectDraft = React.useCallback((projectId: string, projectPath: string) => {
|
||||||
|
setActiveMainTab('chat');
|
||||||
|
if (isMobile) setSessionSwitcherOpen(false);
|
||||||
|
openNewSessionDraft({ selectedProjectId: projectId, directoryOverride: projectPath });
|
||||||
|
handleClose();
|
||||||
|
}, [handleClose, isMobile, openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen]);
|
||||||
|
|
||||||
const handleQuickAdd = React.useCallback((event: React.MouseEvent, path: string) => {
|
const handleQuickAdd = React.useCallback((event: React.MouseEvent, path: string) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const normalized = normalizeDirectoryPath(path);
|
const normalized = normalizeDirectoryPath(path);
|
||||||
if (normalized && addedProjectPaths.has(normalized)) return;
|
if (normalized && addedProjectPaths.has(normalized)) return;
|
||||||
const added = addProject(path);
|
const project = addProject(path);
|
||||||
if (!added) {
|
if (!project) {
|
||||||
toast.error(t('directoryExplorerDialog.toast.failedToAddProject'), {
|
toast.error(t('directoryExplorerDialog.toast.failedToAddProject'), {
|
||||||
description: t('directoryExplorerDialog.toast.selectValidDirectoryPath'),
|
description: t('directoryExplorerDialog.toast.selectValidDirectoryPath'),
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [addProject, addedProjectPaths, t]);
|
openProjectDraft(project.id, project.path);
|
||||||
|
}, [addProject, addedProjectPaths, openProjectDraft, t]);
|
||||||
|
|
||||||
const finalizeSelection = React.useCallback(async (target: string) => {
|
const finalizeSelection = React.useCallback(async (target: string) => {
|
||||||
if (!target || isConfirming) return;
|
if (!target || isConfirming) return;
|
||||||
@@ -439,16 +453,16 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
});
|
});
|
||||||
selectedTarget = result.path;
|
selectedTarget = result.path;
|
||||||
} else if (shouldCreateSelection) {
|
} else if (shouldCreateSelection) {
|
||||||
await opencodeClient.createDirectory(target);
|
await opencodeClient.createDirectory(target, { asProject: true });
|
||||||
}
|
}
|
||||||
const added = addProject(selectedTarget);
|
const project = addProject(selectedTarget);
|
||||||
if (!added) {
|
if (!project) {
|
||||||
toast.error(t('directoryExplorerDialog.toast.failedToAddProject'), {
|
toast.error(t('directoryExplorerDialog.toast.failedToAddProject'), {
|
||||||
description: t('directoryExplorerDialog.toast.selectValidDirectoryPath'),
|
description: t('directoryExplorerDialog.toast.selectValidDirectoryPath'),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleClose();
|
openProjectDraft(project.id, project.path);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t('directoryExplorerDialog.toast.failedToSelectDirectory'), {
|
toast.error(t('directoryExplorerDialog.toast.failedToSelectDirectory'), {
|
||||||
description: error instanceof Error ? error.message : t('directoryExplorerDialog.toast.unknownError'),
|
description: error instanceof Error ? error.message : t('directoryExplorerDialog.toast.unknownError'),
|
||||||
@@ -456,7 +470,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
} finally {
|
} finally {
|
||||||
setIsConfirming(false);
|
setIsConfirming(false);
|
||||||
}
|
}
|
||||||
}, [addProject, addedProjectPaths, cloneRemoteUrl, handleClose, isCloneMode, isConfirming, selectedGitIdentity?.id, shouldCreateTarget, targetPath, t]);
|
}, [addProject, addedProjectPaths, cloneRemoteUrl, isCloneMode, isConfirming, openProjectDraft, selectedGitIdentity?.id, shouldCreateTarget, targetPath, t]);
|
||||||
|
|
||||||
const browseToDisplayPath = React.useCallback((displayPath: string) => {
|
const browseToDisplayPath = React.useCallback((displayPath: string) => {
|
||||||
setQuery(ensureBrowseDirectoryPath(displayPath));
|
setQuery(ensureBrowseDirectoryPath(displayPath));
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
### Components
|
### Components
|
||||||
|
|
||||||
- `SidebarHeader.tsx`: Top header UI for add-project, session search, selection mode, project sort, and the display menu (recent toggle, collapse/expand all).
|
- `SidebarHeader.tsx`: Top header UI for add-project, session search, selection mode, project sort, and the display menu (recent toggle, collapse/expand all).
|
||||||
|
- A successful add/create/clone from the project-directory dialog transitions to a new-session draft targeted at that project, matching the project's `+` action; changing project metadata alone must not leave the visible session or draft on a different directory.
|
||||||
- `SidebarNav.tsx`: Text navigation rows above the tree (New session, Scheduled, Multi-run, Archive); hidden in VS Code.
|
- `SidebarNav.tsx`: Text navigation rows above the tree (New session, Scheduled, Multi-run, Archive); hidden in VS Code.
|
||||||
- `SidebarActivitySections.tsx`: Global top section renderer; currently used for the `recent` section only, styled as a zone header.
|
- `SidebarActivitySections.tsx`: Global top section renderer; currently used for the `recent` section only, styled as a zone header.
|
||||||
- `SidebarFooter.tsx`: Static footer with icon-only settings, shortcuts, and about actions.
|
- `SidebarFooter.tsx`: Static footer with icon-only settings, shortcuts, and about actions.
|
||||||
|
|||||||
@@ -1701,7 +1701,7 @@ class OpencodeService {
|
|||||||
// File System Operations
|
// File System Operations
|
||||||
async createDirectory(
|
async createDirectory(
|
||||||
dirPath: string,
|
dirPath: string,
|
||||||
options?: { allowOutsideWorkspace?: boolean }
|
options?: { allowOutsideWorkspace?: boolean; asProject?: boolean }
|
||||||
): Promise<{ success: boolean; path: string }> {
|
): Promise<{ success: boolean; path: string }> {
|
||||||
const desktopFiles = getDesktopFilesApi();
|
const desktopFiles = getDesktopFilesApi();
|
||||||
if (desktopFiles?.createDirectory) {
|
if (desktopFiles?.createDirectory) {
|
||||||
@@ -1713,6 +1713,24 @@ class OpencodeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options?.asProject) {
|
||||||
|
const response = await runtimeFetch(`${this.baseUrl}/opencode/directory`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ path: dirPath, create: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ error: 'Failed to create project directory' }));
|
||||||
|
throw new Error(error.error || 'Failed to create project directory');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return { success: true, path: result.path };
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
path: dirPath,
|
path: dirPath,
|
||||||
...(options?.allowOutsideWorkspace ? { allowOutsideWorkspace: true } : {}),
|
...(options?.allowOutsideWorkspace ? { allowOutsideWorkspace: true } : {}),
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ This module provides OpenCode server integration utilities for the web server ru
|
|||||||
- `GET /api/config/opencode-resolution`
|
- `GET /api/config/opencode-resolution`
|
||||||
- `POST /api/opencode/upgrade` (enforces the active runtime's upgrade capability, serializes supported OpenCode upgrades, then restarts managed OpenCode so the new binary is active)
|
- `POST /api/opencode/upgrade` (enforces the active runtime's upgrade capability, serializes supported OpenCode upgrades, then restarts managed OpenCode so the new binary is active)
|
||||||
- `GET /api/opencode/upgrade-status` (returns version availability plus the authoritative `upgrade.supported`, `upgrade.manager`, and `upgrade.reason` capability)
|
- `GET /api/opencode/upgrade-status` (returns version availability plus the authoritative `upgrade.supported`, `upgrade.manager`, and `upgrade.reason` capability)
|
||||||
- `POST /api/opencode/directory`
|
- `POST /api/opencode/directory` (validates and activates an existing project directory; `{ create: true }` explicitly creates the requested project directory before activation, including outside the previously active workspace)
|
||||||
- `GET /api/provider/:providerId/source`
|
- `GET /api/provider/:providerId/source`
|
||||||
- `PUT /api/provider` (create/update custom OpenAI-compatible provider config in OpenCode user/project/custom layers via `scope`; secrets stay in auth via the OpenCode auth API)
|
- `PUT /api/provider` (create/update custom OpenAI-compatible provider config in OpenCode user/project/custom layers via `scope`; secrets stay in auth via the OpenCode auth API)
|
||||||
- `DELETE /api/provider/:providerId/auth`
|
- `DELETE /api/provider/:providerId/auth`
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { registerOpenCodeRoutes } from './routes.js';
|
||||||
|
|
||||||
|
const createApp = (overrides = {}) => {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
const dependencies = {
|
||||||
|
fsPromises: { mkdir: vi.fn(async () => undefined) },
|
||||||
|
validateDirectoryPath: vi.fn(async (directory) => ({ ok: true, directory })),
|
||||||
|
readSettingsFromDisk: vi.fn(async () => ({ projects: [] })),
|
||||||
|
sanitizeProjects: (projects) => projects,
|
||||||
|
persistSettings: vi.fn(async (settings) => settings),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
registerOpenCodeRoutes(app, dependencies);
|
||||||
|
return { app, dependencies };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('OpenCode project directory route', () => {
|
||||||
|
it('creates and activates a requested project outside the active workspace', async () => {
|
||||||
|
const { app, dependencies } = createApp();
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/opencode/directory')
|
||||||
|
.send({ path: '/projects/testing-one', create: true })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(dependencies.fsPromises.mkdir).toHaveBeenCalledWith('/projects/testing-one', { recursive: true });
|
||||||
|
expect(dependencies.validateDirectoryPath).toHaveBeenCalledWith('/projects/testing-one');
|
||||||
|
expect(response.body).toMatchObject({ success: true, path: '/projects/testing-one' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not create a directory for the existing activation flow', async () => {
|
||||||
|
const { app, dependencies } = createApp();
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/opencode/directory')
|
||||||
|
.send({ path: '/projects/existing' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(dependencies.fsPromises.mkdir).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,6 +24,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
|||||||
refreshOpenCodeAfterConfigChange,
|
refreshOpenCodeAfterConfigChange,
|
||||||
buildOpenCodeUrl,
|
buildOpenCodeUrl,
|
||||||
getOpenCodeAuthHeaders,
|
getOpenCodeAuthHeaders,
|
||||||
|
fsPromises = fs.promises,
|
||||||
} = dependencies;
|
} = dependencies;
|
||||||
|
|
||||||
let authLibrary = null;
|
let authLibrary = null;
|
||||||
@@ -581,6 +582,10 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
|||||||
return res.status(400).json({ error: 'Path is required' });
|
return res.status(400).json({ error: 'Path is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.body?.create === true) {
|
||||||
|
await fsPromises.mkdir(path.resolve(requestedPath), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
const validated = await validateDirectoryPath(requestedPath);
|
const validated = await validateDirectoryPath(requestedPath);
|
||||||
if (!validated.ok) {
|
if (!validated.ok) {
|
||||||
return res.status(400).json({ error: validated.error });
|
return res.status(400).json({ error: validated.error });
|
||||||
|
|||||||
Reference in New Issue
Block a user