From f9595cb80b6ee124af40d205dd213530a71d4d02 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 10 Aug 2026 16:10:03 +0300 Subject: [PATCH] fix(projects): open draft after adding project --- CHANGELOG.md | 1 + .../session/DirectoryExplorerDialog.tsx | 30 +++++++++---- .../session/sidebar/DOCUMENTATION.md | 1 + packages/ui/src/lib/opencode/client.ts | 20 ++++++++- .../web/server/lib/opencode/DOCUMENTATION.md | 2 +- .../lib/opencode/routes-directory.test.js | 45 +++++++++++++++++++ packages/web/server/lib/opencode/routes.js | 5 +++ 7 files changed, 94 insertions(+), 10 deletions(-) create mode 100644 packages/web/server/lib/opencode/routes-directory.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 095aba5a..a604ce7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. ## [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. - **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). diff --git a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx index c5274f08..56b5dcab 100644 --- a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx +++ b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx @@ -11,7 +11,9 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useUIStore } from '@/stores/useUIStore'; import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { useFileSystemAccess } from '@/hooks/useFileSystemAccess'; import { cn } from '@/lib/utils'; import { toast } from '@/components/ui'; @@ -146,6 +148,9 @@ export const DirectoryExplorerDialog: React.FC = ( const homeDirectory = useDirectoryStore((s) => s.homeDirectory); const projects = useProjectsStore((s) => s.projects); 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 globalGitIdentity = useGitIdentitiesStore((s) => s.globalIdentity); const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId); @@ -405,17 +410,26 @@ export const DirectoryExplorerDialog: React.FC = ( onOpenChange(false); }, [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) => { event.stopPropagation(); const normalized = normalizeDirectoryPath(path); if (normalized && addedProjectPaths.has(normalized)) return; - const added = addProject(path); - if (!added) { + const project = addProject(path); + if (!project) { toast.error(t('directoryExplorerDialog.toast.failedToAddProject'), { 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) => { if (!target || isConfirming) return; @@ -439,16 +453,16 @@ export const DirectoryExplorerDialog: React.FC = ( }); selectedTarget = result.path; } else if (shouldCreateSelection) { - await opencodeClient.createDirectory(target); + await opencodeClient.createDirectory(target, { asProject: true }); } - const added = addProject(selectedTarget); - if (!added) { + const project = addProject(selectedTarget); + if (!project) { toast.error(t('directoryExplorerDialog.toast.failedToAddProject'), { description: t('directoryExplorerDialog.toast.selectValidDirectoryPath'), }); return; } - handleClose(); + openProjectDraft(project.id, project.path); } catch (error) { toast.error(t('directoryExplorerDialog.toast.failedToSelectDirectory'), { description: error instanceof Error ? error.message : t('directoryExplorerDialog.toast.unknownError'), @@ -456,7 +470,7 @@ export const DirectoryExplorerDialog: React.FC = ( } finally { 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) => { setQuery(ensureBrowseDirectoryPath(displayPath)); diff --git a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md index 8f0dc321..ddaf8840 100644 --- a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md +++ b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md @@ -27,6 +27,7 @@ ### Components - `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. - `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. diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index aec681e7..c817acb3 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -1701,7 +1701,7 @@ class OpencodeService { // File System Operations async createDirectory( dirPath: string, - options?: { allowOutsideWorkspace?: boolean } + options?: { allowOutsideWorkspace?: boolean; asProject?: boolean } ): Promise<{ success: boolean; path: string }> { const desktopFiles = getDesktopFilesApi(); 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 = { path: dirPath, ...(options?.allowOutsideWorkspace ? { allowOutsideWorkspace: true } : {}), diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index 412ad025..3b6c453c 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -88,7 +88,7 @@ This module provides OpenCode server integration utilities for the web server ru - `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) - `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` - `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` diff --git a/packages/web/server/lib/opencode/routes-directory.test.js b/packages/web/server/lib/opencode/routes-directory.test.js new file mode 100644 index 00000000..db11b723 --- /dev/null +++ b/packages/web/server/lib/opencode/routes-directory.test.js @@ -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(); + }); +}); diff --git a/packages/web/server/lib/opencode/routes.js b/packages/web/server/lib/opencode/routes.js index 6d5a2be7..617e9667 100644 --- a/packages/web/server/lib/opencode/routes.js +++ b/packages/web/server/lib/opencode/routes.js @@ -24,6 +24,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => { refreshOpenCodeAfterConfigChange, buildOpenCodeUrl, getOpenCodeAuthHeaders, + fsPromises = fs.promises, } = dependencies; let authLibrary = null; @@ -581,6 +582,10 @@ export const registerOpenCodeRoutes = (app, dependencies) => { 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); if (!validated.ok) { return res.status(400).json({ error: validated.error });