diff --git a/CHANGELOG.md b/CHANGELOG.md
index c18b141da..3381d3e06 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -56,6 +56,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- CLI: Local Worker lifecycle controls with `worker status --all` and ownership-safe `worker stop`. (#195)
### 💅 Changed
+- UI: replace Project and Space modal dialogs with compact context-anchored popovers, use the selected folder name for local Projects, keep nested selectors interactive, and show Space-local progress during Worktree operations. (#307)
- UI: refine the console and Control Center with compact proportions, accessible primitives, calibrated theme/toggle styling, and deterministic nested-overlay behavior. (#298)
- UI: refine standby banner notification chrome with an accent hairline, standby pulse, and monospace branch/PR chips. (#297)
- Task: simplify the node header into a Note-style actions menu, shorten the run button label, and compact agent session rows to icon, state, and time. (#294)
diff --git a/docs/ui/WINDOW_UI_STANDARD.md b/docs/ui/WINDOW_UI_STANDARD.md
index 62c288f57..27e191330 100644
--- a/docs/ui/WINDOW_UI_STANDARD.md
+++ b/docs/ui/WINDOW_UI_STANDARD.md
@@ -15,6 +15,17 @@
- Task Delete Confirm
- Agent Launcher
- Settings Panel(作为大型窗口面板)
+- 由右键菜单、工具栏按钮触发的上下文操作浮层
+
+### 1.1 先选择正确的容器
+
+- **上下文操作浮层(Popover)**:重命名、创建 Worktree、归档、挂载选择、删除确认等短流程,应锚定触发项附近;提交前点击外部或按 Esc 关闭,不遮挡整个应用。
+- **局部异步状态**:提交后立即收起浮层;由操作所属的 Space 投射轻量磨砂忙碌状态,只锁定目标 Space,其余 Space 和应用区域保持可操作。
+- **模态窗口(Modal)**:仅用于必须打断全局流程的决策,或无法在小浮层中清晰完成的复杂管理任务。
+
+状态所有权不得放在会因点击外部而卸载的 Popover 内。异步操作必须由更长生命周期的 owner 持有,并保证:同一操作只能完成一次、错误可以重新进入上下文修复、卸载后不提交过期 UI 状态。
+
+Popover 内的 Select、日期选择等 Portal 子层必须登记为同一 transient layer owner:点击子层不得关闭父层,Esc 只关闭最上层。两个完整 surface 之间应使用状态接力,禁止在同一锚点重叠显示。
## 2) 统一视觉 Token
@@ -25,6 +36,7 @@
### 2.2 Surface
- 16px 圆角
- 边框/底色/阴影必须跟随主题 token(例如 `--cove-window-surface`、`--cove-surface*`、`--cove-border*`、`--cove-shadow-color-*`)
+- 上下文操作浮层使用更紧凑的圆角、内边距与菜单同源的材质,避免呈现为缩小版模态框。
### 2.3 Input
- 10px 圆角
@@ -39,10 +51,11 @@
## 3) 交互一致性
-- 点击遮罩关闭窗口(危险动作弹窗除外时需二次确认)
+- 模态窗口点击遮罩关闭;上下文操作浮层点击外部或按 Esc 关闭(提交进行中除外)
- 主按钮始终放最右
- 错误信息用统一 error block 样式
- 小窗优先简洁,复杂编辑由完整弹窗承载
+- 动效应短促且只表达空间关系;必须支持 `prefers-reduced-motion`,不得用动效延迟可操作状态
## 4) 实现约定(当前)
@@ -66,3 +79,6 @@
- 是否使用统一按钮语义(ghost/secondary/primary/danger)
- 是否提供稳定 `data-testid`
- 是否保证键盘与关闭行为一致(Esc / blur / confirm)
+- 是否根据任务范围正确选择 Popover、局部忙碌状态或 Modal
+- 是否让异步 owner 独立于浮层生命周期,并将忙碌状态限制在目标 Space
+- 是否验证亮色、暗色与减少动态效果模式
diff --git a/src/app/renderer/components/AnchoredOperationPopover.tsx b/src/app/renderer/components/AnchoredOperationPopover.tsx
new file mode 100644
index 000000000..f3c03773a
--- /dev/null
+++ b/src/app/renderer/components/AnchoredOperationPopover.tsx
@@ -0,0 +1,74 @@
+import React from 'react'
+import { ViewportMenuSurface } from './ViewportMenuSurface'
+import {
+ targetBelongsToTransientLayer,
+ TransientLayerOwnerProvider,
+} from './TransientLayerOwnerContext'
+
+export interface AnchoredOperationPopoverAnchor {
+ x: number
+ y: number
+}
+
+export function AnchoredOperationPopover({
+ anchor,
+ ariaLabel,
+ children,
+ className,
+ dismissDisabled = false,
+ estimatedHeight = 360,
+ estimatedWidth = 360,
+ onDismiss,
+ testId,
+}: {
+ anchor: AnchoredOperationPopoverAnchor
+ ariaLabel: string
+ children: React.ReactNode
+ className?: string
+ dismissDisabled?: boolean
+ estimatedHeight?: number
+ estimatedWidth?: number
+ onDismiss: () => void
+ testId: string
+}): React.JSX.Element {
+ const ownerId = React.useId()
+
+ return (
+
+ targetBelongsToTransientLayer(target, ownerId)}
+ onDismiss={onDismiss}
+ placement={{
+ type: 'point',
+ point: anchor,
+ alignX: 'start',
+ alignY: 'start',
+ padding: 10,
+ estimatedSize: { width: estimatedWidth, height: estimatedHeight },
+ }}
+ role="dialog"
+ waitForMeasurement
+ onContextMenu={event => {
+ event.stopPropagation()
+ }}
+ onPointerDown={event => {
+ event.stopPropagation()
+ }}
+ onWheel={event => {
+ event.stopPropagation()
+ }}
+ >
+ {children}
+
+
+ )
+}
diff --git a/src/app/renderer/components/CoveSelect.tsx b/src/app/renderer/components/CoveSelect.tsx
index c656ba496..02b5a1faa 100644
--- a/src/app/renderer/components/CoveSelect.tsx
+++ b/src/app/renderer/components/CoveSelect.tsx
@@ -1,6 +1,10 @@
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { ChevronDown } from 'lucide-react'
+import {
+ TRANSIENT_LAYER_OWNER_ATTRIBUTE,
+ useTransientLayerOwner,
+} from './TransientLayerOwnerContext'
import { DismissableLayer } from './ui/DismissableLayer'
import { useIsWithinDialog } from './ui/Dialog'
@@ -52,6 +56,7 @@ export function CoveSelect({
onChange: (nextValue: string) => void
}): React.JSX.Element {
const listboxId = useId()
+ const transientLayerOwner = useTransientLayerOwner()
const isWithinDialog = useIsWithinDialog()
const usesDialogPopoverLayer =
menuLayer === 'dialog-popover' || (menuLayer === 'auto' && isWithinDialog)
@@ -334,6 +339,9 @@ export function CoveSelect({
ref={menuRef}
className={`cove-select__menu${usesDialogPopoverLayer ? ' cove-select__menu--within-dialog' : ''}${menuClassName ? ` ${menuClassName}` : ''}`}
data-testid={menuTestId ?? (testId ? `${testId}-menu` : undefined)}
+ {...(transientLayerOwner
+ ? { [TRANSIENT_LAYER_OWNER_ATTRIBUTE]: transientLayerOwner }
+ : {})}
role="listbox"
branchRefs={[rootRef]}
onDismiss={reason => {
diff --git a/src/app/renderer/components/TransientLayerOwnerContext.tsx b/src/app/renderer/components/TransientLayerOwnerContext.tsx
new file mode 100644
index 000000000..60e34f673
--- /dev/null
+++ b/src/app/renderer/components/TransientLayerOwnerContext.tsx
@@ -0,0 +1,27 @@
+import React from 'react'
+
+export const TRANSIENT_LAYER_OWNER_ATTRIBUTE = 'data-cove-transient-layer-owner'
+
+const TransientLayerOwnerContext = React.createContext(null)
+
+export const TransientLayerOwnerProvider = TransientLayerOwnerContext.Provider
+
+export function useTransientLayerOwner(): string | null {
+ return React.useContext(TransientLayerOwnerContext)
+}
+
+export function targetBelongsToTransientLayer(
+ target: EventTarget | null,
+ ownerId: string,
+): boolean {
+ let element = target instanceof Element ? target : null
+
+ while (element) {
+ if (element.getAttribute(TRANSIENT_LAYER_OWNER_ATTRIBUTE) === ownerId) {
+ return true
+ }
+ element = element.parentElement
+ }
+
+ return false
+}
diff --git a/src/app/renderer/components/ViewportMenuSurface.tsx b/src/app/renderer/components/ViewportMenuSurface.tsx
index eec23e672..374b549f2 100644
--- a/src/app/renderer/components/ViewportMenuSurface.tsx
+++ b/src/app/renderer/components/ViewportMenuSurface.tsx
@@ -35,7 +35,9 @@ export interface ViewportMenuSurfaceProps extends Omit<
dismissOnPointerDownOutside?: boolean
dismissOnEscape?: boolean
dismissIgnoreRefs?: Array>
+ dismissIgnoreTarget?: (target: EventTarget | null) => boolean
stopEventPropagation?: boolean
+ waitForMeasurement?: boolean
}
function assignRef(ref: React.ForwardedRef, value: T): void {
@@ -66,7 +68,9 @@ export const ViewportMenuSurface = React.forwardRef ref.current?.contains(target) ?? false)
+ return (
+ dismissIgnoreRefs.some(ref => ref.current?.contains(target) ?? false) ||
+ dismissIgnoreTarget?.(target) === true
+ )
}
const handlePointerDown = (event: PointerEvent): void => {
@@ -166,6 +173,10 @@ export const ViewportMenuSurface = React.forwardRef {
if (placement.type === 'absolute') {
@@ -203,14 +221,19 @@ export const ViewportMenuSurface = React.forwardRef {
if (stopEventPropagation) {
diff --git a/src/app/renderer/i18n/locales/en.worktree.ts b/src/app/renderer/i18n/locales/en.worktree.ts
index b84b051bd..b9f22048c 100644
--- a/src/app/renderer/i18n/locales/en.worktree.ts
+++ b/src/app/renderer/i18n/locales/en.worktree.ts
@@ -11,6 +11,8 @@ export const enWorktree = {
branchPlaceholder: 'e.g. space/infra-core',
branchHasWorktree: 'worktree exists',
createAndBind: 'Create & Bind',
+ creatingWorktree: 'Creating worktree…',
+ archivingSpace: 'Archiving Space…',
deleteWorktree: 'Delete worktree',
deleteWorktreeHelp: 'Remove the worktree directory from disk.',
deleteBranch: 'Delete branch',
diff --git a/src/app/renderer/i18n/locales/zh-CN.worktree.ts b/src/app/renderer/i18n/locales/zh-CN.worktree.ts
index c4fb9d538..06253a852 100644
--- a/src/app/renderer/i18n/locales/zh-CN.worktree.ts
+++ b/src/app/renderer/i18n/locales/zh-CN.worktree.ts
@@ -11,6 +11,8 @@ export const zhCNWorktree = {
branchPlaceholder: '例如:space/infra-core',
branchHasWorktree: '已有 worktree',
createAndBind: '创建并绑定',
+ creatingWorktree: '正在创建 worktree…',
+ archivingSpace: '正在归档 Space…',
deleteWorktree: '删除 worktree',
deleteWorktreeHelp: '从磁盘移除该 worktree 目录。',
deleteBranch: '删除分支',
diff --git a/src/app/renderer/shell/AppShell.tsx b/src/app/renderer/shell/AppShell.tsx
index b270e20e2..3dedc007f 100644
--- a/src/app/renderer/shell/AppShell.tsx
+++ b/src/app/renderer/shell/AppShell.tsx
@@ -34,6 +34,7 @@ import { useWorkspaceStateHandlers } from './hooks/useWorkspaceStateHandlers'
import { useAppUpdates } from './hooks/useAppUpdates'
import { useAppShellWorkspaceActions } from './hooks/useAppShellWorkspaceActions'
import { useShellOverlayState } from './hooks/useShellOverlayState'
+import { useAddProjectRequest } from './hooks/useAddProjectWizardRequest'
import { useWhatsNew } from './hooks/useWhatsNew'
import { useWorkerSyncStateUpdates } from './hooks/useWorkerSyncStateUpdates'
import { useWorkspaceMountRepair } from './hooks/useWorkspaceMountRepair'
@@ -140,7 +141,7 @@ export default function App(): React.JSX.Element {
const isPrimarySidebarCollapsed = agentSettings.isPrimarySidebarCollapsed === true
- const [isFocusNodeTargetZoomPreviewing, setIsFocusNodeTargetZoomPreviewing] = useState(false)
+ const [isFocusNodeTargetZoomPreviewing, setFocusNodeZoomPreviewing] = useState(false)
const [settingsInitialPageId, setSettingsInitialPageId] = useState(null)
const controlCenterButtonRef = useRef(null)
const {
@@ -151,6 +152,7 @@ export default function App(): React.JSX.Element {
isWorkspaceSearchOpen,
isSpaceArchivesOpen,
isAddProjectWizardOpen,
+ addProjectWizardAnchor,
hasBlockingOverlay,
toggleCommandCenter,
closeCommandCenter,
@@ -231,7 +233,7 @@ export default function App(): React.JSX.Element {
useEffect(() => {
if (!isSettingsOpen) {
- setIsFocusNodeTargetZoomPreviewing(false)
+ setFocusNodeZoomPreviewing(false)
}
}, [isSettingsOpen])
@@ -250,10 +252,7 @@ export default function App(): React.JSX.Element {
onChangeSettings: setAgentSettings,
})
- const handleAddWorkspace = useCallback((): void => {
- setIsFocusNodeTargetZoomPreviewing(false)
- openAddProjectWizard()
- }, [openAddProjectWizard])
+ const handleAddWorkspace = useAddProjectRequest(openAddProjectWizard, setFocusNodeZoomPreviewing)
const {
handleWorkspaceNodesChange,
@@ -287,7 +286,7 @@ export default function App(): React.JSX.Element {
const handleOpenSettings = useCallback(
(initialPageId: SettingsPageId | null = null): void => {
- setIsFocusNodeTargetZoomPreviewing(false)
+ setFocusNodeZoomPreviewing(false)
setSettingsInitialPageId(initialPageId)
closeTransientOverlays()
setSettingsOpenPageId(null)
@@ -435,6 +434,7 @@ export default function App(): React.JSX.Element {
onDeleteSpaceArchiveRecord={handleWorkspaceSpaceArchiveRecordRemove}
onCloseSpaceArchives={closeSpaceArchives}
isAddProjectWizardOpen={isAddProjectWizardOpen}
+ addProjectWizardAnchor={addProjectWizardAnchor}
onCloseAddProjectWizard={closeAddProjectWizard}
projectContextMenu={projectContextMenu}
projectMountManager={projectMountManager}
@@ -466,14 +466,14 @@ export default function App(): React.JSX.Element {
onWorkspaceWorktreesRootChange={handleAnyWorkspaceWorktreesRootChange}
onWorkspaceEnvironmentVariablesChange={handleAnyWorkspaceEnvironmentVariablesChange}
isFocusNodeTargetZoomPreviewing={isFocusNodeTargetZoomPreviewing}
- onFocusNodeTargetZoomPreviewChange={setIsFocusNodeTargetZoomPreviewing}
+ onFocusNodeTargetZoomPreviewChange={setFocusNodeZoomPreviewing}
onChangeSettings={setAgentSettings}
onCheckForUpdates={checkForUpdates}
onDownloadUpdate={downloadUpdate}
onInstallUpdate={installUpdate}
onCloseSettings={() => {
flushPersistNow()
- setIsFocusNodeTargetZoomPreviewing(false)
+ setFocusNodeZoomPreviewing(false)
setSettingsInitialPageId(null)
setSettingsOpenPageId(null)
setIsSettingsOpen(false)
diff --git a/src/app/renderer/shell/components/AddProjectWizardWindow.tsx b/src/app/renderer/shell/components/AddProjectWizardWindow.tsx
index 193d263b7..947e09717 100644
--- a/src/app/renderer/shell/components/AddProjectWizardWindow.tsx
+++ b/src/app/renderer/shell/components/AddProjectWizardWindow.tsx
@@ -1,59 +1,58 @@
-import React, { useCallback, useEffect, useMemo, useState } from 'react'
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { FolderPlus } from 'lucide-react'
+import {
+ AnchoredOperationPopover,
+ type AnchoredOperationPopoverAnchor,
+} from '@app/renderer/components/AnchoredOperationPopover'
import { useTranslation } from '@app/renderer/i18n'
import type { WorkspaceState } from '@contexts/workspace/presentation/renderer/types'
-import { basename, isAbsolutePath } from '../utils/pathHelpers'
+import { basename } from '../utils/pathHelpers'
import { RemoteDirectoryPickerWindow } from './RemoteDirectoryPickerWindow'
import { RemoteEndpointStatusSlot } from './RemoteEndpointStatusSlot'
-import { AddProjectWizardAdvancedSection } from './addProjectWizard/AddProjectWizardAdvancedSection'
import {
AddProjectWizardDefaultLocationSection,
type DefaultLocationKind,
} from './addProjectWizard/AddProjectWizardDefaultLocationSection'
-import type { PlannedMount } from './addProjectWizard/AddProjectWizardPlannedMountsSection'
-import type { DraftMount, RemotePickerState } from './addProjectWizard/helpers'
+import type { RemotePickerState } from './addProjectWizard/helpers'
import { useAddProjectWizardCreateProject } from './addProjectWizard/useAddProjectWizardCreateProject'
import { useAddProjectWizardRemoteEndpoints } from './addProjectWizard/useAddProjectWizardRemoteEndpoints'
+
export function AddProjectWizardWindow({
+ anchor = { x: 24, y: 64 },
existingWorkspaces,
remoteWorkersEnabled,
onClose,
onRequestOpenEndpoints,
}: {
+ anchor?: AnchoredOperationPopoverAnchor
existingWorkspaces: WorkspaceState[]
remoteWorkersEnabled: boolean
onClose: () => void
onRequestOpenEndpoints: () => void
-}): React.JSX.Element {
+}): React.JSX.Element | null {
const { t } = useTranslation()
- const [extraMounts, setExtraMounts] = useState([])
- const [projectName, setProjectName] = useState('')
const [defaultLocationKind, setDefaultLocationKind] = useState('local')
const [defaultLocalRootPath, setDefaultLocalRootPath] = useState('')
const [defaultLocalMountName, setDefaultLocalMountName] = useState('')
- const [defaultRemoteEndpointId, setDefaultRemoteEndpointId] = useState('')
+ const [defaultRemoteEndpointId, setDefaultRemoteEndpointId] = useState('')
const [defaultRemoteRootPath, setDefaultRemoteRootPath] = useState('')
const [defaultRemoteMountName, setDefaultRemoteMountName] = useState('')
- const [isAdvancedOpen, setIsAdvancedOpen] = useState(false)
- const [extraLocalRootPath, setExtraLocalRootPath] = useState('')
- const [extraLocalMountName, setExtraLocalMountName] = useState('')
- const [extraRemoteEndpointId, setExtraRemoteEndpointId] = useState('')
- const [extraRemoteRootPath, setExtraRemoteRootPath] = useState('')
- const [extraRemoteMountName, setExtraRemoteMountName] = useState('')
+ const [unusedExtraRemoteEndpointId, setUnusedExtraRemoteEndpointId] = useState('')
const [remotePicker, setRemotePicker] = useState(null)
const [error, setError] = useState(null)
const [isBusy, setIsBusy] = useState(false)
const [homeWorkerMode, setHomeWorkerMode] = useState<'standalone' | 'local' | 'remote' | null>(
null,
)
+ const [isWorkerModeResolved, setIsWorkerModeResolved] = useState(false)
+ const didOpenNativePickerRef = useRef(false)
+
const {
remoteOverviews,
- endpointLabelById,
endpointOptions,
defaultRemoteOverview,
- extraRemoteOverview,
endpointError,
busyByEndpointId,
- reloadRemoteOverviews,
runRemoteEndpointAction,
reconnectRemoteEndpoint,
} = useAddProjectWizardRemoteEndpoints({
@@ -61,8 +60,8 @@ export function AddProjectWizardWindow({
t,
defaultRemoteEndpointId,
setDefaultRemoteEndpointId,
- extraRemoteEndpointId,
- setExtraRemoteEndpointId,
+ extraRemoteEndpointId: unusedExtraRemoteEndpointId,
+ setExtraRemoteEndpointId: setUnusedExtraRemoteEndpointId,
setError,
})
@@ -72,271 +71,148 @@ export function AddProjectWizardWindow({
homeWorkerMode !== 'remote'
useEffect(() => {
- void (async () => {
- try {
- const config = await window.opencoveApi.workerClient.getConfig()
- setHomeWorkerMode(config.mode)
- } catch {
- setHomeWorkerMode(null)
- }
- })()
- }, [])
+ let cancelled = false
+ void window.opencoveApi.workerClient
+ .getConfig()
+ .then(config => {
+ if (!cancelled) {
+ setHomeWorkerMode(config.mode)
+ }
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setHomeWorkerMode(null)
+ }
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setIsWorkerModeResolved(true)
+ }
+ })
- useEffect(() => {
- if (!remoteWorkersEnabled) {
- setDefaultLocationKind('local')
+ return () => {
+ cancelled = true
}
- }, [remoteWorkersEnabled])
+ }, [])
const derivedProjectName = useMemo(() => {
- const trimmed = projectName.trim()
- if (trimmed.length > 0) {
- return trimmed
- }
+ const rootPath = defaultLocationKind === 'local' ? defaultLocalRootPath : defaultRemoteRootPath
+ return basename(rootPath).trim()
+ }, [defaultLocalRootPath, defaultLocationKind, defaultRemoteRootPath])
- const candidateRoot =
- defaultLocationKind === 'local' ? defaultLocalRootPath.trim() : defaultRemoteRootPath.trim()
- if (candidateRoot.length > 0) {
- return basename(candidateRoot).trim()
- }
-
- const fallback = extraMounts[0]?.rootPath
- return fallback ? basename(fallback).trim() : ''
- }, [defaultLocalRootPath, defaultLocationKind, defaultRemoteRootPath, extraMounts, projectName])
-
- const displayError = error ?? endpointError
-
- const defaultMountPreview = useMemo(() => {
- if (!remoteWorkersEnabled) {
- const rootPath = defaultLocalRootPath.trim()
- if (rootPath.length === 0) {
- return null
- }
-
- return {
- endpointId: 'local',
- rootPath,
- name: defaultLocalMountName.trim() || basename(rootPath).trim() || null,
- }
- }
-
- if (defaultLocationKind === 'local') {
- const rootPath = defaultLocalRootPath.trim()
- if (rootPath.length === 0) {
- return null
- }
-
- return {
- endpointId: 'local',
- rootPath,
- name: defaultLocalMountName.trim() || basename(rootPath).trim() || null,
- }
- }
-
- const endpointId = defaultRemoteEndpointId.trim()
- const rootPath = defaultRemoteRootPath.trim()
- if (endpointId.length === 0 || rootPath.length === 0) {
- return null
- }
-
- return {
- endpointId,
- rootPath,
- name: defaultRemoteMountName.trim() || basename(rootPath).trim() || null,
- }
- }, [
- defaultLocalMountName,
- defaultLocalRootPath,
+ const createProject = useAddProjectWizardCreateProject({
+ t,
+ existingWorkspaces,
+ onClose,
+ isBusy,
+ setIsBusy,
+ setError,
+ derivedProjectName,
defaultLocationKind,
+ defaultLocalRootPath,
+ defaultLocalMountName,
defaultRemoteEndpointId,
- defaultRemoteMountName,
defaultRemoteRootPath,
- remoteWorkersEnabled,
- ])
-
- const addExtraMountDraft = useCallback((draft: Omit) => {
- setExtraMounts(prev => [
- ...prev,
- {
- id: crypto.randomUUID(),
- ...draft,
- },
- ])
- }, [])
-
- const removeExtraMountDraft = useCallback((draftId: string) => {
- setExtraMounts(prev => prev.filter(item => item.id !== draftId))
- }, [])
-
- const browseDefaultLocalMount = useCallback(async () => {
- if (!canBrowseLocal) {
- return
- }
-
- const selected = await window.opencoveApi.workspace.selectDirectory()
- if (!selected) {
- return
- }
-
- setDefaultLocalRootPath(selected.path)
- if (defaultLocalMountName.trim().length === 0) {
- setDefaultLocalMountName(selected.name)
- }
- }, [canBrowseLocal, defaultLocalMountName])
-
- const browseExtraLocalMount = useCallback(async () => {
- if (!canBrowseLocal) {
- return
- }
-
- const selected = await window.opencoveApi.workspace.selectDirectory()
- if (!selected) {
- return
- }
-
- setExtraLocalRootPath(selected.path)
- if (extraLocalMountName.trim().length === 0) {
- setExtraLocalMountName(selected.name)
- }
- }, [canBrowseLocal, extraLocalMountName])
+ defaultRemoteMountName,
+ extraMounts: [],
+ })
- const openRemotePicker = useCallback(
- (target: 'default' | 'extra') => {
- const endpointId =
- target === 'default' ? defaultRemoteEndpointId.trim() : extraRemoteEndpointId.trim()
- if (endpointId.length === 0) {
+ const chooseLocalFolder = useCallback(
+ async (createImmediately: boolean) => {
+ if (!canBrowseLocal || isBusy) {
return
}
- const endpointLabel = endpointLabelById.get(endpointId) ?? endpointId
- const initialPath =
- target === 'default' ? defaultRemoteRootPath.trim() : extraRemoteRootPath.trim()
+ const selected = await window.opencoveApi.workspace.selectDirectory()
+ if (!selected) {
+ if (!remoteWorkersEnabled) {
+ onClose()
+ }
+ return
+ }
- setRemotePicker({
- target,
- endpointId,
- endpointLabel,
- initialPath: initialPath.length > 0 ? initialPath : null,
- })
+ setDefaultLocationKind('local')
+ setDefaultLocalRootPath(selected.path)
+ setDefaultLocalMountName(selected.name)
+ if (createImmediately) {
+ await createProject({
+ derivedProjectName: selected.name || basename(selected.path),
+ defaultLocationKind: 'local',
+ defaultLocalRootPath: selected.path,
+ defaultLocalMountName: selected.name,
+ extraMounts: [],
+ })
+ }
},
- [
- defaultRemoteEndpointId,
- defaultRemoteRootPath,
- endpointLabelById,
- extraRemoteEndpointId,
- extraRemoteRootPath,
- ],
+ [canBrowseLocal, createProject, isBusy, onClose, remoteWorkersEnabled],
)
- const addExtraLocalMount = useCallback(() => {
- const rootPath = extraLocalRootPath.trim()
- if (rootPath.length === 0) {
- return
- }
-
- if (!isAbsolutePath(rootPath)) {
- setError(t('addProjectWizard.localPathMustBeAbsolute'))
+ useEffect(() => {
+ if (
+ remoteWorkersEnabled ||
+ !isWorkerModeResolved ||
+ !canBrowseLocal ||
+ didOpenNativePickerRef.current
+ ) {
return
}
- addExtraMountDraft({
- endpointId: 'local',
- rootPath,
- name: extraLocalMountName.trim() || basename(rootPath).trim() || null,
- })
- setExtraLocalRootPath('')
- setExtraLocalMountName('')
- }, [addExtraMountDraft, extraLocalMountName, extraLocalRootPath, t])
-
- const addExtraRemoteMount = useCallback(() => {
- const endpointId = extraRemoteEndpointId.trim()
- const rootPath = extraRemoteRootPath.trim()
- if (endpointId.length === 0 || rootPath.length === 0) {
- return
- }
+ didOpenNativePickerRef.current = true
+ void chooseLocalFolder(true)
+ }, [canBrowseLocal, chooseLocalFolder, isWorkerModeResolved, remoteWorkersEnabled])
- if (!isAbsolutePath(rootPath)) {
- setError(t('addProjectWizard.remotePathMustBeAbsolute'))
+ const openRemotePicker = useCallback(() => {
+ const endpointId = defaultRemoteEndpointId.trim()
+ if (endpointId.length === 0) {
return
}
- addExtraMountDraft({
+ const endpointLabel =
+ endpointOptions.find(option => option.value === endpointId)?.label ?? endpointId
+ setRemotePicker({
+ target: 'default',
endpointId,
- rootPath,
- name: extraRemoteMountName.trim() || basename(rootPath).trim() || null,
+ endpointLabel,
+ initialPath: defaultRemoteRootPath.trim() || null,
})
- setExtraRemoteRootPath('')
- setExtraRemoteMountName('')
- }, [addExtraMountDraft, extraRemoteEndpointId, extraRemoteMountName, extraRemoteRootPath, t])
+ }, [defaultRemoteEndpointId, defaultRemoteRootPath, endpointOptions])
- const createProject = useAddProjectWizardCreateProject({
- t,
- existingWorkspaces,
- onClose,
- isBusy,
- setIsBusy,
- setError,
- derivedProjectName,
- defaultLocationKind,
- defaultLocalRootPath,
- defaultLocalMountName,
- defaultRemoteEndpointId,
- defaultRemoteRootPath,
- defaultRemoteMountName,
- extraMounts,
- })
+ if (!remoteWorkersEnabled && canBrowseLocal && !error) {
+ return null
+ }
- const canCreateExtraRemote =
- extraRemoteEndpointId.trim().length > 0 && extraRemoteRootPath.trim().length > 0
+ const displayError = error ?? endpointError
return (
<>
- {
- if (isBusy) {
- return
- }
-
- onClose()
- }}
- >
-
event.stopPropagation()}
+ {remotePicker === null ? (
+
- {t('addProjectWizard.title')}
-
- {remoteWorkersEnabled
- ? t('addProjectWizard.description')
- : t('addProjectWizard.descriptionLocalOnly')}
-
+
+
-
{displayError ? (
{displayError}
) : null}
-
-
- setProjectName(event.target.value)}
- disabled={isBusy}
- placeholder={t('addProjectWizard.namePlaceholder')}
- data-testid="workspace-project-create-name"
- />
-
-
}
onChangeDefaultLocationKind={setDefaultLocationKind}
- onChangeDefaultLocalRootPath={setDefaultLocalRootPath}
- onBrowseDefaultLocalRootPath={() => void browseDefaultLocalMount()}
+ onChangeDefaultLocalRootPath={value => {
+ setDefaultLocalRootPath(value)
+ setDefaultLocalMountName(basename(value))
+ }}
+ onBrowseDefaultLocalRootPath={() => {
+ void chooseLocalFolder(true)
+ }}
onChangeDefaultRemoteEndpointId={setDefaultRemoteEndpointId}
- onChangeDefaultRemoteRootPath={setDefaultRemoteRootPath}
- onBrowseDefaultRemoteRootPath={() => {
- if (!remoteWorkersEnabled) {
- return
- }
-
- openRemotePicker('default')
+ onChangeDefaultRemoteRootPath={value => {
+ setDefaultRemoteRootPath(value)
+ setDefaultRemoteMountName(basename(value))
}}
+ onBrowseDefaultRemoteRootPath={openRemotePicker}
onRequestOpenEndpoints={onRequestOpenEndpoints}
/>
- {remoteWorkersEnabled ? (
-
{
- void runRemoteEndpointAction(endpointId)
- }}
- onReconnect={endpointId => {
- void reconnectRemoteEndpoint(endpointId)
- }}
- />
- }
- onToggleAdvanced={() => setIsAdvancedOpen(open => !open)}
- onChangeExtraLocalRootPath={setExtraLocalRootPath}
- onChangeExtraLocalMountName={setExtraLocalMountName}
- onBrowseExtraLocalRootPath={() => void browseExtraLocalMount()}
- onAddExtraLocalMount={addExtraLocalMount}
- onChangeExtraRemoteEndpointId={setExtraRemoteEndpointId}
- onChangeExtraRemoteRootPath={setExtraRemoteRootPath}
- onChangeExtraRemoteMountName={setExtraRemoteMountName}
- onBrowseExtraRemoteRootPath={() => {
- openRemotePicker('extra')
- }}
- onAddExtraRemoteMount={() => {
- addExtraRemoteMount()
- }}
- onRemoveExtraMount={removeExtraMountDraft}
- onReloadEndpoints={() => {
- void reloadRemoteOverviews()
+
+
+
-
-
-
-
-
-
-
+ data-testid="workspace-project-create-confirm"
+ >
+ {isBusy ? t('common.loading') : t('common.create')}
+
+
+
+
+ ) : null}
{
- const target = remotePicker?.target ?? null
setRemotePicker(null)
-
- if (!target) {
- return
- }
-
- if (target === 'default') {
- setDefaultRemoteRootPath(path)
- if (defaultRemoteMountName.trim().length === 0) {
- setDefaultRemoteMountName(basename(path))
- }
- return
- }
-
- setExtraRemoteRootPath(path)
- if (extraRemoteMountName.trim().length === 0) {
- setExtraRemoteMountName(basename(path))
- }
+ setDefaultRemoteRootPath(path)
+ setDefaultRemoteMountName(basename(path))
}}
/>
>
diff --git a/src/app/renderer/shell/components/AppShellPopups.tsx b/src/app/renderer/shell/components/AppShellPopups.tsx
index b0c21130d..d811ac5b8 100644
--- a/src/app/renderer/shell/components/AppShellPopups.tsx
+++ b/src/app/renderer/shell/components/AppShellPopups.tsx
@@ -38,6 +38,7 @@ export function AppShellPopups({
onDeleteSpaceArchiveRecord,
onCloseSpaceArchives,
isAddProjectWizardOpen,
+ addProjectWizardAnchor,
onCloseAddProjectWizard,
projectContextMenu,
projectMountManager,
@@ -60,7 +61,7 @@ export function AppShellPopups({
onRequestOpenEndpoints: () => void
onOpenSpaceArchives: () => void
onTogglePrimarySidebar: () => void
- onAddWorkspace: () => void
+ onAddWorkspace: (anchor?: { x: number; y: number }) => void
onSelectWorkspace: (workspaceId: string) => void
onSelectSpace: (spaceId: string | null) => void
isSpaceArchivesOpen: boolean
@@ -70,6 +71,7 @@ export function AppShellPopups({
onDeleteSpaceArchiveRecord: (recordId: string) => void
onCloseSpaceArchives: () => void
isAddProjectWizardOpen: boolean
+ addProjectWizardAnchor: { x: number; y: number }
onCloseAddProjectWizard: () => void
projectContextMenu: ProjectContextMenuState | null
projectMountManager: ProjectMountManagerState | null
@@ -126,6 +128,7 @@ export function AppShellPopups({
{isAddProjectWizardOpen ? (
{
diff --git a/src/app/renderer/shell/components/Sidebar.tsx b/src/app/renderer/shell/components/Sidebar.tsx
index 1da8b8e54..e8730736b 100644
--- a/src/app/renderer/shell/components/Sidebar.tsx
+++ b/src/app/renderer/shell/components/Sidebar.tsx
@@ -43,7 +43,7 @@ type SidebarProps = {
activeWorkspaceId: string | null
persistNotice: PersistNotice | null
onTogglePinned?: () => void
- onAddProject?: () => void
+ onAddProject?: (anchor?: { x: number; y: number }) => void
onSelectWorkspace: (workspaceId: string) => void
onSelectSpace: (workspaceId: string, spaceId: string) => void
onOpenProjectContextMenu: (state: ProjectContextMenuState) => void
diff --git a/src/app/renderer/shell/components/SidebarToolbar.tsx b/src/app/renderer/shell/components/SidebarToolbar.tsx
index 2782d78e4..704207da3 100644
--- a/src/app/renderer/shell/components/SidebarToolbar.tsx
+++ b/src/app/renderer/shell/components/SidebarToolbar.tsx
@@ -11,7 +11,7 @@ export function SidebarToolbar({
isPinned: boolean
showAddProject: boolean
onTogglePinned: () => void
- onAddProject: () => void
+ onAddProject: (anchor?: { x: number; y: number }) => void
}): React.JSX.Element {
const { t } = useTranslation()
const pinLabel = isPinned ? t('sidebar.unpin') : t('sidebar.pin')
@@ -36,7 +36,13 @@ export function SidebarToolbar({
data-testid="workspace-sidebar-add-project"
aria-label={t('sidebar.addProject')}
title={t('sidebar.addProject')}
- onClick={onAddProject}
+ onClick={event => {
+ const rect = event.currentTarget.getBoundingClientRect()
+ onAddProject({
+ x: Math.round(rect.left),
+ y: Math.round(rect.bottom + 6),
+ })
+ }}
>
diff --git a/src/app/renderer/shell/components/addProjectWizard/useAddProjectWizardCreateProject.ts b/src/app/renderer/shell/components/addProjectWizard/useAddProjectWizardCreateProject.ts
index 21c133152..df093aff8 100644
--- a/src/app/renderer/shell/components/addProjectWizard/useAddProjectWizardCreateProject.ts
+++ b/src/app/renderer/shell/components/addProjectWizard/useAddProjectWizardCreateProject.ts
@@ -38,145 +38,171 @@ export function useAddProjectWizardCreateProject(options: {
defaultRemoteRootPath: string
defaultRemoteMountName: string
extraMounts: DraftMount[]
-}): () => Promise {
- return useCallback(async () => {
- if (options.isBusy) {
- return
- }
-
- options.setError(null)
-
- const name = options.derivedProjectName.trim()
- if (name.length === 0) {
- options.setError(options.t('addProjectWizard.nameRequired'))
- return
- }
-
- const defaultMount: PlannedMount | null =
- options.defaultLocationKind === 'local'
- ? (() => {
- const rootPath = options.defaultLocalRootPath.trim()
- if (rootPath.length === 0) {
- options.setError(options.t('addProjectWizard.defaultMountRequired'))
- return null
- }
-
- if (!isAbsolutePath(rootPath)) {
- options.setError(options.t('addProjectWizard.localPathMustBeAbsolute'))
- return null
- }
-
- return {
- endpointId: 'local',
- rootPath,
- name: resolveMountName(rootPath, options.defaultLocalMountName),
- }
- })()
- : (() => {
- const endpointId = options.defaultRemoteEndpointId.trim()
- const rootPath = options.defaultRemoteRootPath.trim()
- if (endpointId.length === 0 || rootPath.length === 0) {
- options.setError(options.t('addProjectWizard.defaultMountRequired'))
- return null
- }
-
- if (!isAbsolutePath(rootPath)) {
- options.setError(options.t('addProjectWizard.remotePathMustBeAbsolute'))
- return null
- }
-
- return {
- endpointId,
- rootPath,
- name: resolveMountName(rootPath, options.defaultRemoteMountName),
- }
- })()
-
- if (!defaultMount) {
- return
- }
-
- if (options.existingWorkspaces.some(workspace => workspace.name.trim() === name)) {
- // allow duplicates, but warn via subtle error messaging
- }
-
- const mountsToCreate: PlannedMount[] = [
- defaultMount,
- ...options.extraMounts.map(mount => ({
- endpointId: mount.endpointId,
- rootPath: mount.rootPath,
- name: mount.name,
- })),
- ]
-
- const projectId = crypto.randomUUID()
-
- options.setIsBusy(true)
- const createdMountIds: string[] = []
- try {
- const firstLocalMount = mountsToCreate.find(mount => mount.endpointId === 'local') ?? null
- const workspacePath = firstLocalMount
- ? firstLocalMount.rootPath
- : (
- await window.opencoveApi.controlSurface.invoke({
- kind: 'command',
- id: 'workspace.allocateProjectPlaceholder',
- payload: { projectId },
- })
- ).path
-
- await mountsToCreate.reduce>((acc, mount) => {
- return acc.then(async () => {
- const created = await window.opencoveApi.controlSurface.invoke({
- kind: 'command',
- id: 'mount.create',
- payload: {
- projectId,
- endpointId: mount.endpointId,
- rootPath: mount.rootPath,
- name: mount.name,
- },
- })
- createdMountIds.push(created.mount.mountId)
- })
- }, Promise.resolve())
-
- const nextWorkspace: WorkspaceState = {
- id: projectId,
- name,
- path: workspacePath,
- nodes: [],
- worktreesRoot: '',
- viewport: createDefaultWorkspaceViewport(),
- isMinimapVisible: DEFAULT_WORKSPACE_MINIMAP_VISIBLE,
- spaces: [],
- activeSpaceId: null,
- spaceArchiveRecords: [],
+}): (
+ overrides?: Partial<{
+ derivedProjectName: string
+ defaultLocationKind: DefaultLocationKind
+ defaultLocalRootPath: string
+ defaultLocalMountName: string
+ defaultRemoteEndpointId: string
+ defaultRemoteRootPath: string
+ defaultRemoteMountName: string
+ extraMounts: DraftMount[]
+ }>,
+) => Promise {
+ return useCallback(
+ async overrides => {
+ if (options.isBusy) {
+ return
+ }
+
+ options.setError(null)
+
+ const name = (overrides?.derivedProjectName ?? options.derivedProjectName).trim()
+ if (name.length === 0) {
+ options.setError(options.t('addProjectWizard.nameRequired'))
+ return
}
- const store = useAppStore.getState()
- store.setWorkspaces(prev => [...prev, nextWorkspace])
- store.setActiveWorkspaceId(nextWorkspace.id)
- store.setFocusRequest(null)
-
- notifyTopologyChanged()
- options.onClose()
- } catch (caughtError) {
- await Promise.all(
- createdMountIds.map(mountId =>
- window.opencoveApi.controlSurface
- .invoke({
+ const defaultMount: PlannedMount | null =
+ (overrides?.defaultLocationKind ?? options.defaultLocationKind) === 'local'
+ ? (() => {
+ const rootPath = (
+ overrides?.defaultLocalRootPath ?? options.defaultLocalRootPath
+ ).trim()
+ if (rootPath.length === 0) {
+ options.setError(options.t('addProjectWizard.defaultMountRequired'))
+ return null
+ }
+
+ if (!isAbsolutePath(rootPath)) {
+ options.setError(options.t('addProjectWizard.localPathMustBeAbsolute'))
+ return null
+ }
+
+ return {
+ endpointId: 'local',
+ rootPath,
+ name: resolveMountName(
+ rootPath,
+ overrides?.defaultLocalMountName ?? options.defaultLocalMountName,
+ ),
+ }
+ })()
+ : (() => {
+ const endpointId = (
+ overrides?.defaultRemoteEndpointId ?? options.defaultRemoteEndpointId
+ ).trim()
+ const rootPath = (
+ overrides?.defaultRemoteRootPath ?? options.defaultRemoteRootPath
+ ).trim()
+ if (endpointId.length === 0 || rootPath.length === 0) {
+ options.setError(options.t('addProjectWizard.defaultMountRequired'))
+ return null
+ }
+
+ if (!isAbsolutePath(rootPath)) {
+ options.setError(options.t('addProjectWizard.remotePathMustBeAbsolute'))
+ return null
+ }
+
+ return {
+ endpointId,
+ rootPath,
+ name: resolveMountName(
+ rootPath,
+ overrides?.defaultRemoteMountName ?? options.defaultRemoteMountName,
+ ),
+ }
+ })()
+
+ if (!defaultMount) {
+ return
+ }
+
+ if (options.existingWorkspaces.some(workspace => workspace.name.trim() === name)) {
+ // allow duplicates, but warn via subtle error messaging
+ }
+
+ const mountsToCreate: PlannedMount[] = [
+ defaultMount,
+ ...(overrides?.extraMounts ?? options.extraMounts).map(mount => ({
+ endpointId: mount.endpointId,
+ rootPath: mount.rootPath,
+ name: mount.name,
+ })),
+ ]
+
+ const projectId = crypto.randomUUID()
+
+ options.setIsBusy(true)
+ const createdMountIds: string[] = []
+ try {
+ const firstLocalMount = mountsToCreate.find(mount => mount.endpointId === 'local') ?? null
+ const workspacePath = firstLocalMount
+ ? firstLocalMount.rootPath
+ : (
+ await window.opencoveApi.controlSurface.invoke({
+ kind: 'command',
+ id: 'workspace.allocateProjectPlaceholder',
+ payload: { projectId },
+ })
+ ).path
+
+ await mountsToCreate.reduce>((acc, mount) => {
+ return acc.then(async () => {
+ const created = await window.opencoveApi.controlSurface.invoke({
kind: 'command',
- id: 'mount.remove',
- payload: { mountId },
+ id: 'mount.create',
+ payload: {
+ projectId,
+ endpointId: mount.endpointId,
+ rootPath: mount.rootPath,
+ name: mount.name,
+ },
})
- .catch(() => undefined),
- ),
- )
-
- options.setError(toErrorMessage(caughtError))
- } finally {
- options.setIsBusy(false)
- }
- }, [options])
+ createdMountIds.push(created.mount.mountId)
+ })
+ }, Promise.resolve())
+
+ const nextWorkspace: WorkspaceState = {
+ id: projectId,
+ name,
+ path: workspacePath,
+ nodes: [],
+ worktreesRoot: '',
+ viewport: createDefaultWorkspaceViewport(),
+ isMinimapVisible: DEFAULT_WORKSPACE_MINIMAP_VISIBLE,
+ spaces: [],
+ activeSpaceId: null,
+ spaceArchiveRecords: [],
+ }
+
+ const store = useAppStore.getState()
+ store.setWorkspaces(prev => [...prev, nextWorkspace])
+ store.setActiveWorkspaceId(nextWorkspace.id)
+ store.setFocusRequest(null)
+
+ notifyTopologyChanged()
+ options.onClose()
+ } catch (caughtError) {
+ await Promise.all(
+ createdMountIds.map(mountId =>
+ window.opencoveApi.controlSurface
+ .invoke({
+ kind: 'command',
+ id: 'mount.remove',
+ payload: { mountId },
+ })
+ .catch(() => undefined),
+ ),
+ )
+
+ options.setError(toErrorMessage(caughtError))
+ } finally {
+ options.setIsBusy(false)
+ }
+ },
+ [options],
+ )
}
diff --git a/src/app/renderer/shell/hooks/useAddProjectWizardRequest.ts b/src/app/renderer/shell/hooks/useAddProjectWizardRequest.ts
new file mode 100644
index 000000000..46be3ce01
--- /dev/null
+++ b/src/app/renderer/shell/hooks/useAddProjectWizardRequest.ts
@@ -0,0 +1,19 @@
+import { useCallback, type Dispatch, type SetStateAction } from 'react'
+
+interface AddProjectWizardAnchor {
+ x: number
+ y: number
+}
+
+export function useAddProjectRequest(
+ openAddProjectWizard: (anchor?: AddProjectWizardAnchor) => void,
+ setIsFocusNodeTargetZoomPreviewing: Dispatch>,
+): (anchor?: AddProjectWizardAnchor) => void {
+ return useCallback(
+ (anchor?: AddProjectWizardAnchor): void => {
+ setIsFocusNodeTargetZoomPreviewing(false)
+ openAddProjectWizard(anchor)
+ },
+ [openAddProjectWizard, setIsFocusNodeTargetZoomPreviewing],
+ )
+}
diff --git a/src/app/renderer/shell/hooks/useShellOverlayState.ts b/src/app/renderer/shell/hooks/useShellOverlayState.ts
index b4502d8f9..21fe6adb1 100644
--- a/src/app/renderer/shell/hooks/useShellOverlayState.ts
+++ b/src/app/renderer/shell/hooks/useShellOverlayState.ts
@@ -8,6 +8,7 @@ export function useShellOverlayState(): {
isWorkspaceSearchOpen: boolean
isSpaceArchivesOpen: boolean
isAddProjectWizardOpen: boolean
+ addProjectWizardAnchor: { x: number; y: number }
hasBlockingOverlay: boolean
toggleCommandCenter: () => void
closeCommandCenter: () => void
@@ -21,7 +22,7 @@ export function useShellOverlayState(): {
closeWorkspaceSearch: () => void
openSpaceArchives: () => void
closeSpaceArchives: () => void
- openAddProjectWizard: () => void
+ openAddProjectWizard: (anchor?: { x: number; y: number }) => void
closeAddProjectWizard: () => void
closeTransientOverlays: () => void
} {
@@ -32,6 +33,7 @@ export function useShellOverlayState(): {
const [isWorkspaceSearchOpen, setIsWorkspaceSearchOpen] = useState(false)
const [isSpaceArchivesOpen, setIsSpaceArchivesOpen] = useState(false)
const [isAddProjectWizardOpen, setIsAddProjectWizardOpen] = useState(false)
+ const [addProjectWizardAnchor, setAddProjectWizardAnchor] = useState({ x: 24, y: 64 })
const closeCommandCenter = useCallback((): void => setIsCommandCenterOpen(false), [])
const closeControlCenter = useCallback((): void => setIsControlCenterOpen(false), [])
@@ -105,10 +107,16 @@ export function useShellOverlayState(): {
setIsSpaceArchivesOpen(true)
}, [])
- const openAddProjectWizard = useCallback((): void => {
- closeTransientOverlays()
- setIsAddProjectWizardOpen(true)
- }, [closeTransientOverlays])
+ const openAddProjectWizard = useCallback(
+ (anchor?: { x: number; y: number }): void => {
+ closeTransientOverlays()
+ if (anchor) {
+ setAddProjectWizardAnchor(anchor)
+ }
+ setIsAddProjectWizardOpen(true)
+ },
+ [closeTransientOverlays],
+ )
return {
isCommandCenterOpen,
@@ -118,13 +126,13 @@ export function useShellOverlayState(): {
isWorkspaceSearchOpen,
isSpaceArchivesOpen,
isAddProjectWizardOpen,
+ addProjectWizardAnchor,
hasBlockingOverlay:
isCommandCenterOpen ||
isControlCenterOpen ||
isIssueReportOpen ||
isWorkspaceSearchOpen ||
- isSpaceArchivesOpen ||
- isAddProjectWizardOpen,
+ isSpaceArchivesOpen,
toggleCommandCenter,
closeCommandCenter,
toggleControlCenter,
diff --git a/src/app/renderer/styles.css b/src/app/renderer/styles.css
index 670fdbae6..a13b181de 100644
--- a/src/app/renderer/styles.css
+++ b/src/app/renderer/styles.css
@@ -46,6 +46,9 @@
@import './styles/settings-panel.diagnostics.css';
@import './styles/settings-panel.groups.css';
@import './styles/cove-window.css';
+@import './styles/anchored-operation-popover.css';
+@import './styles/workspace-operation-popovers.css';
+@import './styles/workspace-space-operation-state.css';
@import './styles/remote-ui.css';
@import './styles/remote-ui.settings.css';
@import './styles/app-message.css';
diff --git a/src/app/renderer/styles/anchored-operation-popover.css b/src/app/renderer/styles/anchored-operation-popover.css
new file mode 100644
index 000000000..59634820e
--- /dev/null
+++ b/src/app/renderer/styles/anchored-operation-popover.css
@@ -0,0 +1,60 @@
+.anchored-operation-popover {
+ position: fixed;
+ z-index: 10040;
+ --cove-contextual-surface-width: 360px;
+ --cove-contextual-control-height: 32px;
+ width: min(var(--cove-contextual-surface-width), calc(100vw - 20px));
+ max-height: min(520px, calc(100vh - 20px));
+ overflow: auto;
+ border: 1px solid color-mix(in srgb, var(--cove-border) 76%, transparent);
+ border-radius: 12px;
+ background: color-mix(in srgb, var(--cove-window-surface) 92%, transparent);
+ box-shadow:
+ 0 18px 42px color-mix(in srgb, var(--cove-shadow-color-panel) 72%, transparent),
+ 0 2px 8px color-mix(in srgb, var(--cove-shadow-color-elevated) 44%, transparent);
+ color: var(--cove-text);
+ backdrop-filter: blur(24px) saturate(1.18);
+ -webkit-backdrop-filter: blur(24px) saturate(1.18);
+ transform-origin: top left;
+}
+
+.anchored-operation-popover[data-viewport-menu-measured='true'] {
+ animation: anchored-operation-popover-in 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+
+.anchored-operation-popover .cove-field {
+ min-height: var(--cove-contextual-control-height);
+ border-radius: 8px;
+ font-size: calc(11px * var(--cove-ui-font-scale));
+}
+
+.cove-select__menu[data-cove-transient-layer-owner] {
+ z-index: 10060;
+ padding: 4px;
+ border-radius: 10px;
+}
+
+.cove-select__menu[data-cove-transient-layer-owner] .cove-select__option {
+ min-height: 30px;
+ padding: 6px 8px;
+ border-radius: 7px;
+ font-size: calc(11px * var(--cove-ui-font-scale));
+}
+
+@keyframes anchored-operation-popover-in {
+ from {
+ opacity: 0;
+ transform: translateY(-3px) scale(0.985);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .anchored-operation-popover[data-viewport-menu-measured='true'] {
+ animation: none;
+ }
+}
diff --git a/src/app/renderer/styles/workspace-operation-popovers.css b/src/app/renderer/styles/workspace-operation-popovers.css
new file mode 100644
index 000000000..1b690bdf1
--- /dev/null
+++ b/src/app/renderer/styles/workspace-operation-popovers.css
@@ -0,0 +1,485 @@
+.workspace-space-worktree-popover {
+ width: min(var(--cove-contextual-surface-width), calc(100vw - 20px));
+ padding: 0;
+}
+
+.workspace-space-worktree-popover--archive {
+ width: min(var(--cove-contextual-surface-width), calc(100vw - 20px));
+}
+
+.workspace-space-worktree-popover .workspace-space-worktree.cove-window {
+ width: 100%;
+ max-height: inherit;
+ gap: 12px;
+ padding: 14px;
+ overflow: auto;
+ border: 0;
+ border-radius: inherit;
+ background: transparent;
+ box-shadow: none;
+}
+
+.workspace-space-worktree-popover .workspace-space-worktree--archive.cove-window {
+ border: 0;
+ background: transparent;
+ box-shadow: none;
+}
+
+.workspace-space-worktree-popover .workspace-space-worktree__header-main {
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 6px 10px;
+}
+
+.workspace-space-worktree-popover .workspace-space-worktree__header-main h3 {
+ overflow: hidden;
+ font-size: calc(14px * var(--cove-ui-font-scale));
+ font-weight: 650;
+ letter-spacing: -0.01em;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.workspace-space-worktree-popover .workspace-space-worktree__header-summary {
+ font-size: calc(11px * var(--cove-ui-font-scale));
+}
+
+.workspace-space-worktree-popover .workspace-space-worktree__status-line {
+ grid-column: 1 / -1;
+ grid-row: 2;
+ justify-self: start;
+ max-width: 100%;
+ padding: 3px 8px;
+ border-color: color-mix(in srgb, var(--cove-border) 68%, transparent);
+ background: color-mix(in srgb, var(--cove-surface-strong) 72%, transparent);
+ color: var(--cove-text-muted);
+ font-size: calc(10px * var(--cove-ui-font-scale));
+}
+
+.workspace-space-worktree-popover .workspace-space-worktree__close {
+ grid-column: 2;
+ grid-row: 1;
+ width: 26px;
+ height: 26px;
+ border: 0;
+ border-radius: 7px;
+ background: transparent;
+ color: var(--cove-text-muted);
+}
+
+.workspace-space-worktree-popover .workspace-space-worktree__close:hover:not(:disabled) {
+ border: 0;
+ background: var(--cove-surface-hover);
+ color: var(--cove-text);
+}
+
+.workspace-space-worktree-popover .workspace-space-worktree__view {
+ gap: 0;
+}
+
+.workspace-space-worktree-popover .workspace-space-worktree__view-header {
+ display: none;
+}
+
+.workspace-space-worktree-popover .workspace-space-worktree__surface {
+ gap: 12px;
+ padding-top: 12px;
+ border-top-color: color-mix(in srgb, var(--cove-border) 52%, transparent);
+ border-radius: 0;
+}
+
+.workspace-space-worktree-popover .workspace-space-worktree__segment-control {
+ width: 100%;
+ gap: 2px;
+ padding: 2px;
+ border: 1px solid color-mix(in srgb, var(--cove-border) 62%, transparent);
+ border-radius: 9px;
+ background: color-mix(in srgb, var(--cove-surface-strong) 48%, transparent);
+}
+
+.workspace-space-worktree-popover .workspace-space-worktree__segment {
+ flex: 1;
+ padding: 6px 8px;
+ border: 0;
+ border-radius: 7px;
+ color: var(--cove-text-muted);
+ font-size: calc(11px * var(--cove-ui-font-scale));
+}
+
+.workspace-space-worktree-popover .workspace-space-worktree__segment:hover:not(:disabled) {
+ background: color-mix(in srgb, var(--cove-surface-hover) 70%, transparent);
+ color: var(--cove-text);
+}
+
+.workspace-space-worktree-popover .workspace-space-worktree__segment--active {
+ background: var(--cove-surface-strong);
+ box-shadow: 0 1px 3px var(--cove-shadow-color-elevated);
+ color: var(--cove-text);
+}
+
+.workspace-space-worktree-popover .workspace-space-worktree__content-block {
+ gap: 10px;
+ padding-top: 10px;
+ border-top: 0;
+}
+
+.workspace-space-worktree-popover .workspace-space-worktree__create-grid {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 9px;
+}
+
+.workspace-space-worktree-popover .workspace-space-worktree__create-grid-span-two {
+ grid-column: auto;
+}
+
+.workspace-space-worktree-popover .cove-window__field-row {
+ gap: 5px;
+}
+
+.workspace-space-worktree-popover .cove-window__field-row label,
+.workspace-space-worktree-popover .workspace-space-worktree__option-copy strong,
+.workspace-space-worktree-popover .workspace-space-worktree__option-copy span {
+ font-size: calc(11px * var(--cove-ui-font-scale));
+}
+
+.workspace-space-worktree-popover .workspace-space-worktree__inline-actions {
+ gap: 7px;
+}
+
+.workspace-space-worktree-popover .cove-window__action {
+ min-height: 30px;
+ padding: 6px 11px;
+ border-radius: 8px;
+ font-size: calc(11px * var(--cove-ui-font-scale));
+}
+
+.workspace-space-worktree-popover .workspace-space-worktree__option-list,
+.workspace-space-worktree-popover .workspace-space-worktree__descendant-cleanups {
+ gap: 7px;
+}
+
+.workspace-space-worktree-popover .workspace-space-worktree__option-row {
+ gap: 8px;
+ padding: 7px 8px;
+ border-radius: 9px;
+ background: color-mix(in srgb, var(--cove-surface-strong) 44%, transparent);
+}
+
+.workspace-operation-guard {
+ display: grid;
+ gap: 11px;
+ padding: 14px;
+}
+
+.workspace-operation-guard__header {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 9px;
+ align-items: center;
+}
+
+.workspace-operation-guard__icon {
+ display: grid;
+ place-items: center;
+ width: 28px;
+ height: 28px;
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--cove-label-orange) 14%, transparent);
+ color: var(--cove-label-orange);
+}
+
+.workspace-operation-guard__header h3,
+.workspace-operation-guard__header p,
+.workspace-operation-guard__lead,
+.workspace-operation-guard__supporting,
+.workspace-operation-guard__error {
+ margin: 0;
+}
+
+.workspace-operation-guard__header h3 {
+ font-size: calc(13px * var(--cove-ui-font-scale));
+}
+
+.workspace-operation-guard__header p,
+.workspace-operation-guard__supporting {
+ color: var(--cove-text-muted);
+ font-size: calc(11px * var(--cove-ui-font-scale));
+ line-height: 1.4;
+}
+
+.workspace-operation-guard__lead {
+ color: var(--cove-text);
+ font-size: calc(12px * var(--cove-ui-font-scale));
+ line-height: 1.45;
+}
+
+.workspace-operation-guard__actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 7px;
+ padding-top: 2px;
+}
+
+.workspace-operation-guard__actions .cove-window__action {
+ min-height: 30px;
+ padding: 6px 10px;
+ border-radius: 8px;
+ font-size: calc(11px * var(--cove-ui-font-scale));
+}
+
+.workspace-space-branch-rename-popover {
+ width: min(var(--cove-contextual-surface-width), calc(100vw - 20px));
+}
+
+.workspace-space-branch-rename-popover .workspace-space-branch-rename {
+ display: grid;
+ gap: 11px;
+ padding: 14px;
+}
+
+.workspace-space-branch-rename-popover .workspace-space-branch-rename__header {
+ gap: 2px;
+}
+
+.workspace-space-branch-rename-popover .workspace-space-branch-rename__header h3 {
+ font-size: calc(13px * var(--cove-ui-font-scale));
+ letter-spacing: -0.01em;
+}
+
+.workspace-space-branch-rename-popover .workspace-space-branch-rename__meta,
+.workspace-space-branch-rename-popover .workspace-space-branch-rename__prompt {
+ color: var(--cove-text-muted);
+ font-size: calc(11px * var(--cove-ui-font-scale));
+ line-height: 1.4;
+}
+
+.workspace-space-branch-rename-popover .workspace-space-branch-rename__actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 7px;
+ margin-top: 0;
+}
+
+.workspace-space-branch-rename-popover .cove-window__action {
+ min-height: 30px;
+ padding: 6px 10px;
+ border-radius: 8px;
+ font-size: calc(11px * var(--cove-ui-font-scale));
+}
+
+.workspace-space-target-mount-popover {
+ width: min(var(--cove-contextual-surface-width), calc(100vw - 20px));
+}
+
+.workspace-space-target-mount {
+ display: grid;
+ gap: 9px;
+ padding: 14px;
+}
+
+.workspace-space-target-mount > h3,
+.workspace-space-target-mount > p {
+ margin: 0;
+}
+
+.workspace-space-target-mount > h3 {
+ font-size: calc(13px * var(--cove-ui-font-scale));
+ letter-spacing: -0.01em;
+}
+
+.workspace-space-target-mount > p {
+ color: var(--cove-text-muted);
+ font-size: calc(11px * var(--cove-ui-font-scale));
+ line-height: 1.4;
+}
+
+.workspace-space-target-mount__list {
+ display: grid;
+ gap: 5px;
+ width: 100%;
+}
+
+.workspace-space-target-mount__option {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 8px;
+ align-items: start;
+ padding: 8px 9px;
+ border: 1px solid color-mix(in srgb, var(--cove-border) 64%, transparent);
+ border-radius: 9px;
+ background: color-mix(in srgb, var(--cove-field) 72%, transparent);
+ cursor: pointer;
+}
+
+.workspace-space-target-mount__option:has(input:checked) {
+ border-color: color-mix(in srgb, var(--cove-accent) 52%, var(--cove-border));
+ background: color-mix(in srgb, var(--cove-accent) 9%, var(--cove-field));
+}
+
+.workspace-space-target-mount__option input {
+ margin-top: 2px;
+}
+
+.workspace-space-target-mount__option-copy {
+ display: grid;
+ gap: 1px;
+ min-width: 0;
+}
+
+.workspace-space-target-mount__option-copy strong {
+ color: var(--cove-text);
+ font-size: calc(11px * var(--cove-ui-font-scale));
+}
+
+.workspace-space-target-mount__option-copy span {
+ overflow: hidden;
+ color: var(--cove-text-muted);
+ font-size: calc(10px * var(--cove-ui-font-scale));
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.workspace-space-target-mount .cove-window__actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 7px;
+ margin-top: 2px;
+}
+
+.workspace-space-target-mount .cove-window__action {
+ min-height: 30px;
+ padding: 6px 10px;
+ border-radius: 8px;
+ font-size: calc(11px * var(--cove-ui-font-scale));
+}
+
+.workspace-operation-guard__checkbox {
+ padding: 7px 8px;
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--cove-field) 62%, transparent);
+ font-size: calc(11px * var(--cove-ui-font-scale));
+}
+
+.workspace-operation-guard--danger .workspace-operation-guard__icon {
+ background: color-mix(in srgb, var(--cove-overlay-danger-status-text) 12%, transparent);
+ color: var(--cove-overlay-danger-status-text);
+}
+
+.workspace-project-create-popover {
+ width: min(var(--cove-contextual-surface-width), calc(100vw - 20px));
+}
+
+.workspace-project-create {
+ display: grid;
+ gap: 12px;
+ padding: 14px;
+}
+
+.workspace-project-create__header {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 9px;
+ align-items: center;
+}
+
+.workspace-project-create__icon {
+ display: grid;
+ place-items: center;
+ width: 28px;
+ height: 28px;
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--cove-accent) 12%, transparent);
+ color: var(--cove-accent);
+}
+
+.workspace-project-create__header h3,
+.workspace-project-create__header p {
+ margin: 0;
+}
+
+.workspace-project-create__header h3 {
+ font-size: calc(13px * var(--cove-ui-font-scale));
+ letter-spacing: -0.01em;
+}
+
+.workspace-project-create__header p {
+ margin-top: 2px;
+ color: var(--cove-text-muted);
+ font-size: calc(10px * var(--cove-ui-font-scale));
+ line-height: 1.35;
+}
+
+.workspace-project-create .cove-window__field-row {
+ gap: 7px;
+}
+
+.workspace-project-create .cove-window__label-row > label {
+ font-size: calc(11px * var(--cove-ui-font-scale));
+}
+
+.workspace-project-create .cove-window__segmented {
+ padding: 2px;
+ border-radius: 8px;
+}
+
+.workspace-project-create .cove-window__segment {
+ min-height: 26px;
+ padding: 4px 9px;
+ border-radius: 6px;
+ font-size: calc(10px * var(--cove-ui-font-scale));
+}
+
+.workspace-project-create .cove-window__section-card {
+ gap: 8px;
+ padding: 9px;
+ border-radius: 10px;
+}
+
+.workspace-project-create .remote-endpoint-status--compact {
+ padding: 8px;
+ border-radius: 9px;
+}
+
+.workspace-project-create
+ .remote-endpoint-status--compact.remote-endpoint-status--success
+ .remote-endpoint-status__title-group {
+ flex-direction: row;
+ align-items: center;
+ gap: 7px;
+}
+
+.workspace-project-create
+ .remote-endpoint-status--compact.remote-endpoint-status--success
+ :is(.remote-endpoint-status__meta, .remote-endpoint-status__summary) {
+ display: none;
+}
+
+.workspace-project-create .remote-endpoint-status__badge {
+ min-height: 22px;
+ padding: 2px 8px;
+ font-size: calc(10px * var(--cove-ui-font-scale));
+}
+
+.workspace-project-create .cove-window__path-row {
+ gap: 6px;
+}
+
+.workspace-project-create .cove-window__action {
+ min-height: 30px;
+ padding: 6px 10px;
+ border-radius: 8px;
+ font-size: calc(11px * var(--cove-ui-font-scale));
+}
+
+.workspace-project-create__actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 7px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .workspace-space-worktree-popover *,
+ .workspace-space-worktree-guard-popover * {
+ transition-duration: 0.01ms !important;
+ }
+}
diff --git a/src/app/renderer/styles/workspace-space-operation-state.css b/src/app/renderer/styles/workspace-space-operation-state.css
new file mode 100644
index 000000000..bbe53b565
--- /dev/null
+++ b/src/app/renderer/styles/workspace-space-operation-state.css
@@ -0,0 +1,66 @@
+.workspace-canvas .react-flow__viewport-portal:has(.workspace-space-region--busy) {
+ z-index: 2;
+}
+
+.workspace-space-region__operation-state {
+ position: absolute;
+ z-index: 20;
+ inset: -1px;
+ display: grid;
+ place-items: center;
+ overflow: hidden;
+ border: 1px solid color-mix(in srgb, var(--cove-border) 72%, transparent);
+ border-radius: inherit;
+ background: color-mix(in srgb, var(--cove-window-surface) 34%, transparent);
+ backdrop-filter: blur(5px) saturate(0.82);
+ -webkit-backdrop-filter: blur(5px) saturate(0.82);
+ pointer-events: auto;
+ animation: workspace-space-operation-state-in 160ms ease-out;
+}
+
+.workspace-space-region__operation-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ max-width: calc(100% - 24px);
+ padding: 7px 10px;
+ border: 1px solid color-mix(in srgb, var(--cove-border) 68%, transparent);
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--cove-window-surface) 88%, transparent);
+ box-shadow: 0 6px 18px var(--cove-shadow-color-elevated);
+ color: var(--cove-text);
+ font-size: calc(11px * var(--cove-ui-font-scale));
+ line-height: 1.2;
+ white-space: nowrap;
+}
+
+.workspace-space-region__operation-pill svg {
+ width: 14px;
+ height: 14px;
+ flex: 0 0 auto;
+ color: var(--cove-accent);
+ animation: workspace-space-operation-spinner 850ms linear infinite;
+}
+
+@keyframes workspace-space-operation-state-in {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes workspace-space-operation-spinner {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .workspace-space-region__operation-state,
+ .workspace-space-region__operation-pill svg {
+ animation: none;
+ }
+}
diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/WorkspaceCanvasView.tsx b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/WorkspaceCanvasView.tsx
index 25e422236..5324d6553 100644
--- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/WorkspaceCanvasView.tsx
+++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/WorkspaceCanvasView.tsx
@@ -14,10 +14,12 @@ import {
import type { TerminalNodeData } from '../../types'
import { MAX_CANVAS_ZOOM, MIN_CANVAS_ZOOM } from './constants'
import type { WorkspaceCanvasViewProps } from './WorkspaceCanvasView.types'
+import { useActiveExplorerSpace } from './hooks/useActiveExplorerSpace'
import { useWorkspaceCanvasGlobalDismissals } from './hooks/useGlobalDismissals'
import { useWorkspaceCanvasSpaceMenuState } from './hooks/useCanvasSpaceMenuState'
import { useWorkspaceCanvasLabelColorFilter } from './hooks/useLabelColorFilter'
import { useViewportDprSnapping } from './hooks/useViewportDprSnapping'
+import { useSpaceWorktreeBusyLabels } from './hooks/useSpaceWorktreeBusyLabels'
import { WorkspaceCanvasMenus } from './view/WorkspaceCanvasMenus'
import { WorkspaceCanvasWindows } from './view/WorkspaceCanvasWindows'
import { WorkspaceMinimapDock } from './view/WorkspaceMinimapDock'
@@ -164,11 +166,12 @@ export function WorkspaceCanvasView({
closeSpaceActionMenu,
copySpacePath,
openSpacePath,
- spaceWorktreeDialog,
+ spaceWorktreeOperations,
worktreesRoot,
openSpaceCreateWorktree,
openSpaceArchive,
closeSpaceWorktree,
+ setSpaceWorktreeOperationPhase,
onShowMessage,
onAppendSpaceArchiveRecord,
updateSpaceDirectory,
@@ -179,6 +182,7 @@ export function WorkspaceCanvasView({
const isDragSurfaceSelectionMode = useStore(selectDragSurfaceSelectionMode)
const { labelColorFilter, setLabelColorFilter, usedLabelColors, filteredNodes, filteredEdges } =
useWorkspaceCanvasLabelColorFilter({ nodes, edges, spaces })
+ const busyOperationBySpaceId = useSpaceWorktreeBusyLabels(spaceWorktreeOperations)
useWorkspaceCanvasGlobalDismissals({
contextMenu,
@@ -204,13 +208,7 @@ export function WorkspaceCanvasView({
nodes,
})
- const activeExplorerSpace = React.useMemo(() => {
- if (!openExplorerSpaceId) {
- return null
- }
-
- return spaces.find(space => space.id === openExplorerSpaceId) ?? null
- }, [openExplorerSpaceId, spaces])
+ const activeExplorerSpace = useActiveExplorerSpace(openExplorerSpaceId, spaces)
React.useEffect(() => {
if (!useManualCanvasWheelGestures) {
@@ -335,6 +333,7 @@ export function WorkspaceCanvasView({
void
copySpacePath: (spaceId: string) => Promise | void
openSpacePath: (spaceId: string, openerId: WorkspacePathOpenerId) => Promise | void
- spaceWorktreeDialog: SpaceWorktreeDialogState | null
+ spaceWorktreeOperations: SpaceWorktreeOperationState[]
worktreesRoot: string
- openSpaceCreateWorktree: (spaceId: string) => void
- openSpaceArchive: (spaceId: string) => void
- closeSpaceWorktree: () => void
+ openSpaceCreateWorktree: (spaceId: string, anchor: { x: number; y: number }) => void
+ openSpaceArchive: (spaceId: string, anchor: { x: number; y: number }) => void
+ closeSpaceWorktree: (operationId: string) => void
+ setSpaceWorktreeOperationPhase: (operationId: string, phase: SpaceWorktreeOperationPhase) => void
onShowMessage?: WorkspaceCanvasProps['onShowMessage']
onAppendSpaceArchiveRecord: WorkspaceCanvasProps['onAppendSpaceArchiveRecord']
updateSpaceDirectory: (
diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useActiveExplorerSpace.ts b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useActiveExplorerSpace.ts
new file mode 100644
index 000000000..cb91009fa
--- /dev/null
+++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useActiveExplorerSpace.ts
@@ -0,0 +1,15 @@
+import React from 'react'
+import type { WorkspaceSpaceState } from '../../../types'
+
+export function useActiveExplorerSpace(
+ openExplorerSpaceId: string | null,
+ spaces: WorkspaceSpaceState[],
+): WorkspaceSpaceState | null {
+ return React.useMemo(() => {
+ if (!openExplorerSpaceId) {
+ return null
+ }
+
+ return spaces.find(space => space.id === openExplorerSpaceId) ?? null
+ }, [openExplorerSpaceId, spaces])
+}
diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.worktreeWarning.ts b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.worktreeWarning.ts
index 789b2240b..49ce67ab8 100644
--- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.worktreeWarning.ts
+++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.worktreeWarning.ts
@@ -197,6 +197,7 @@ export function useWorkspaceCanvasSpaceOwnershipWorktreeWarning({
spaceName: targetSpace?.name ?? t('worktree.workspaceRoot'),
agentCount,
terminalCount,
+ anchor: reactFlow.flowToScreenPosition(dropTargetPoint),
})
const nodeIdSet = new Set(draggedNodeIds)
diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceUi.ts b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceUi.ts
index 4c5fd4c36..f6b1f63e3 100644
--- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceUi.ts
+++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceUi.ts
@@ -1,11 +1,12 @@
-import { useCallback, useEffect, useState, type MutableRefObject } from 'react'
+import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react'
import type { Node } from '@xyflow/react'
import type { WorkspacePathOpener, WorkspacePathOpenerId } from '@shared/contracts/dto'
import type { TerminalNodeData, WorkspaceSpaceState } from '../../../types'
import type {
ContextMenuState,
EmptySelectionPromptState,
- SpaceWorktreeDialogState,
+ SpaceWorktreeOperationPhase,
+ SpaceWorktreeOperationState,
} from '../types'
export function useWorkspaceCanvasSpaceUi({
@@ -35,7 +36,7 @@ export function useWorkspaceCanvasSpaceUi({
) => void
}): {
spaceActionMenu: { spaceId: string; x: number; y: number } | null
- spaceWorktreeDialog: SpaceWorktreeDialogState | null
+ spaceWorktreeOperations: SpaceWorktreeOperationState[]
availablePathOpeners: WorkspacePathOpener[]
onCanvasClick: () => void
closeContextMenu: () => void
@@ -47,18 +48,20 @@ export function useWorkspaceCanvasSpaceUi({
closeSpaceActionMenu: () => void
copySpacePath: (spaceId: string) => Promise
openSpacePath: (spaceId: string, openerId: WorkspacePathOpenerId) => Promise
- openSpaceCreateWorktree: (spaceId: string) => void
- openSpaceArchive: (spaceId: string) => void
- closeSpaceWorktree: () => void
+ openSpaceCreateWorktree: (spaceId: string, anchor: { x: number; y: number }) => void
+ openSpaceArchive: (spaceId: string, anchor: { x: number; y: number }) => void
+ closeSpaceWorktree: (operationId: string) => void
+ setSpaceWorktreeOperationPhase: (operationId: string, phase: SpaceWorktreeOperationPhase) => void
} {
const [spaceActionMenu, setSpaceActionMenu] = useState<{
spaceId: string
x: number
y: number
} | null>(null)
- const [spaceWorktreeDialog, setSpaceWorktreeDialog] = useState(
- null,
- )
+ const [spaceWorktreeOperations, setSpaceWorktreeOperations] = useState<
+ SpaceWorktreeOperationState[]
+ >([])
+ const nextSpaceWorktreeOperationIdRef = useRef(0)
const [availablePathOpeners, setAvailablePathOpeners] = useState([])
useEffect(() => {
@@ -183,23 +186,63 @@ export function useWorkspaceCanvasSpaceUi({
[resolveSpacePath],
)
- const openSpaceCreateWorktree = useCallback((spaceId: string) => {
- setSpaceActionMenu(null)
- setSpaceWorktreeDialog({ spaceId, initialViewMode: 'create' })
- }, [])
+ const openSpaceWorktreeOperation = useCallback(
+ (
+ spaceId: string,
+ initialViewMode: SpaceWorktreeOperationState['initialViewMode'],
+ anchor: { x: number; y: number },
+ ) => {
+ setSpaceActionMenu(null)
+ const operationId = `space-worktree-${nextSpaceWorktreeOperationIdRef.current}`
+ nextSpaceWorktreeOperationIdRef.current += 1
+ setSpaceWorktreeOperations(previous => [
+ ...previous.filter(operation => operation.phase === 'running'),
+ {
+ id: operationId,
+ spaceId,
+ initialViewMode,
+ anchor,
+ phase: 'draft',
+ },
+ ])
+ },
+ [],
+ )
- const openSpaceArchive = useCallback((spaceId: string) => {
- setSpaceActionMenu(null)
- setSpaceWorktreeDialog({ spaceId, initialViewMode: 'archive' })
- }, [])
+ const openSpaceCreateWorktree = useCallback(
+ (spaceId: string, anchor: { x: number; y: number }) => {
+ openSpaceWorktreeOperation(spaceId, 'create', anchor)
+ },
+ [openSpaceWorktreeOperation],
+ )
- const closeSpaceWorktree = useCallback(() => {
- setSpaceWorktreeDialog(null)
+ const openSpaceArchive = useCallback(
+ (spaceId: string, anchor: { x: number; y: number }) => {
+ openSpaceWorktreeOperation(spaceId, 'archive', anchor)
+ },
+ [openSpaceWorktreeOperation],
+ )
+
+ const closeSpaceWorktree = useCallback((operationId: string) => {
+ setSpaceWorktreeOperations(previous =>
+ previous.filter(operation => operation.id !== operationId),
+ )
}, [])
+ const setSpaceWorktreeOperationPhase = useCallback(
+ (operationId: string, phase: SpaceWorktreeOperationPhase) => {
+ setSpaceWorktreeOperations(previous =>
+ previous.map(operation =>
+ operation.id === operationId ? { ...operation, phase } : operation,
+ ),
+ )
+ },
+ [],
+ )
+
return {
spaceActionMenu,
- spaceWorktreeDialog,
+ spaceWorktreeOperations,
availablePathOpeners,
onCanvasClick,
closeContextMenu,
@@ -214,5 +257,6 @@ export function useWorkspaceCanvasSpaceUi({
openSpaceCreateWorktree,
openSpaceArchive,
closeSpaceWorktree,
+ setSpaceWorktreeOperationPhase,
}
}
diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceWorktreeBusyLabels.ts b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceWorktreeBusyLabels.ts
new file mode 100644
index 000000000..ec93f1c0f
--- /dev/null
+++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceWorktreeBusyLabels.ts
@@ -0,0 +1,26 @@
+import React from 'react'
+import { useTranslation } from '@app/renderer/i18n'
+import type { SpaceWorktreeOperationState } from '../types'
+
+export function useSpaceWorktreeBusyLabels(
+ operations: SpaceWorktreeOperationState[],
+): ReadonlyMap {
+ const { t } = useTranslation()
+
+ return React.useMemo(() => {
+ const labels = new Map()
+ for (const operation of operations) {
+ if (operation.phase !== 'running') {
+ continue
+ }
+
+ labels.set(
+ operation.spaceId,
+ operation.initialViewMode === 'archive'
+ ? t('worktree.archivingSpace')
+ : t('worktree.creatingWorktree'),
+ )
+ }
+ return labels
+ }, [operations, t])
+}
diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaces.createSpaceSelection.ts b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaces.createSpaceSelection.ts
index 31eb971d0..a38f0e848 100644
--- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaces.createSpaceSelection.ts
+++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaces.createSpaceSelection.ts
@@ -121,6 +121,22 @@ export function createSpaceFromSelectedNodesWithMounts({
rect: null,
mounts: mountResult.mounts,
selectedMountId: mountResult.mounts[0].mountId,
+ anchor: (() => {
+ const firstSelectedNode = reactFlow
+ .getNodes()
+ .find(node => selectedIds.includes(node.id))
+ if (!firstSelectedNode) {
+ return { x: 24, y: 24 }
+ }
+
+ return reactFlow.flowToScreenPosition({
+ x:
+ firstSelectedNode.position.x +
+ (firstSelectedNode.measured?.width ?? firstSelectedNode.width ?? 240) +
+ 8,
+ y: firstSelectedNode.position.y,
+ })
+ })(),
})
setContextMenu(null)
setEmptySelectionPrompt(null)
diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/types.ts b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/types.ts
index 5741e89e5..279ffddea 100644
--- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/types.ts
+++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/types.ts
@@ -73,9 +73,14 @@ export interface SpaceActionMenuState {
y: number
}
-export interface SpaceWorktreeDialogState {
+export type SpaceWorktreeOperationPhase = 'draft' | 'running' | 'error'
+
+export interface SpaceWorktreeOperationState {
+ id: string
spaceId: string
initialViewMode: 'create' | 'archive'
+ anchor: { x: number; y: number }
+ phase: SpaceWorktreeOperationPhase
}
export interface SelectionDraftState {
@@ -102,6 +107,7 @@ export interface SpaceTargetMountPickerState {
rect: WorkspaceSpaceRect | null
mounts: MountDto[]
selectedMountId: string
+ anchor: { x: number; y: number }
}
export interface SpaceVisual {
@@ -203,6 +209,7 @@ export interface SpaceWorktreeMismatchDropWarningState {
spaceName: string
agentCount: number
terminalCount: number
+ anchor: { x: number; y: number }
}
export interface CreateNodeInput {
diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceCanvasMenus.tsx b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceCanvasMenus.tsx
index 992dcb397..d19c16021 100644
--- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceCanvasMenus.tsx
+++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceCanvasMenus.tsx
@@ -157,14 +157,14 @@ export function WorkspaceCanvasMenus({
preserveWindowSizes={arrangePreserveWindowSizes}
onChangePreserveWindowSizes={setArrangePreserveWindowSizes}
onArrange={spaceId => arrangeInSpace(spaceId, arrangeStyle)}
- onCreateWorktree={() => {
+ onCreateWorktree={anchor => {
if (activeMenuSpace) {
- openSpaceCreateWorktree(activeMenuSpace.id)
+ openSpaceCreateWorktree(activeMenuSpace.id, anchor)
}
}}
- onArchive={() => {
+ onArchive={anchor => {
if (activeMenuSpace) {
- openSpaceArchive(activeMenuSpace.id)
+ openSpaceArchive(activeMenuSpace.id, anchor)
}
}}
onCopyPath={() => {
diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceCanvasWindows.tsx b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceCanvasWindows.tsx
index 1774751c9..8381001a9 100644
--- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceCanvasWindows.tsx
+++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceCanvasWindows.tsx
@@ -36,13 +36,14 @@ export function WorkspaceCanvasWindows({
spaceWorktreeMismatchDropWarning,
cancelSpaceWorktreeMismatchDropWarning,
continueSpaceWorktreeMismatchDropWarning,
- spaceWorktreeDialog,
+ spaceWorktreeOperations,
spaces,
nodes,
workspacePath,
worktreesRoot,
agentSettings,
closeSpaceWorktree,
+ setSpaceWorktreeOperationPhase,
onShowMessage,
onAppendSpaceArchiveRecord,
updateSpaceDirectory,
@@ -77,13 +78,14 @@ export function WorkspaceCanvasWindows({
| 'spaceWorktreeMismatchDropWarning'
| 'cancelSpaceWorktreeMismatchDropWarning'
| 'continueSpaceWorktreeMismatchDropWarning'
- | 'spaceWorktreeDialog'
+ | 'spaceWorktreeOperations'
| 'spaces'
| 'nodes'
| 'workspacePath'
| 'worktreesRoot'
| 'agentSettings'
| 'closeSpaceWorktree'
+ | 'setSpaceWorktreeOperationPhase'
| 'onShowMessage'
| 'onAppendSpaceArchiveRecord'
| 'updateSpaceDirectory'
@@ -140,23 +142,33 @@ export function WorkspaceCanvasWindows({
onContinue={continueSpaceWorktreeMismatchDropWarning}
/>
- {
- updateSpaceDirectory(spaceId, directoryPath, options)
- }}
- getBlockingNodes={spaceId => getSpaceBlockingNodes(spaceId)}
- closeNodesById={nodeIds => closeNodesById(nodeIds)}
- />
+ {spaceWorktreeOperations.map(operation => (
+ {
+ closeSpaceWorktree(operation.id)
+ }}
+ onOperationPhaseChange={phase => {
+ setSpaceWorktreeOperationPhase(operation.id, phase)
+ }}
+ onShowMessage={onShowMessage}
+ onAppendSpaceArchiveRecord={onAppendSpaceArchiveRecord}
+ onUpdateSpaceDirectory={(spaceId, directoryPath, options) => {
+ updateSpaceDirectory(spaceId, directoryPath, options)
+ }}
+ getBlockingNodes={spaceId => getSpaceBlockingNodes(spaceId)}
+ closeNodesById={nodeIds => closeNodesById(nodeIds)}
+ />
+ ))}
>
)
}
diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceSpaceActionMenu.tsx b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceSpaceActionMenu.tsx
index 5fa785574..08e8e0327 100644
--- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceSpaceActionMenu.tsx
+++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceSpaceActionMenu.tsx
@@ -34,8 +34,8 @@ interface WorkspaceSpaceActionMenuProps {
setSpaceLabelColor: (spaceId: string, labelColor: LabelColor | null) => void
onChangePreserveWindowSizes?: (enabled: boolean) => void
onArrange?: (spaceId: string) => void
- onCreateWorktree: () => void
- onArchive: () => void
+ onCreateWorktree: (anchor: { x: number; y: number }) => void
+ onArchive: (anchor: { x: number; y: number }) => void
onCopyPath: () => void | Promise
onOpenPath: (openerId: WorkspacePathOpenerId) => void | Promise
}
@@ -298,8 +298,9 @@ export function WorkspaceSpaceActionMenu({
- }
- onBackdropClick={onCancelDelete}
- dialogClassName="workspace-warning-dialog--compact"
- actions={
- <>
+
+
+
+
{t('common.delete')}
- >
- }
- />
+
+
+
) : null}
>
)
diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceSpaceRegion.tsx b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceSpaceRegion.tsx
index 7e4d8f39f..18d4c84f3 100644
--- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceSpaceRegion.tsx
+++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceSpaceRegion.tsx
@@ -204,6 +204,7 @@ export function WorkspaceSpaceRegion({
title={resolvedBranchBadge.title}
onClick={event => {
event.stopPropagation()
+ const rect = event.currentTarget.getBoundingClientRect()
setBranchRename({
spaceId: space.id,
spaceName: space.name,
@@ -213,6 +214,10 @@ export function WorkspaceSpaceRegion({
nextName: branchName,
isSubmitting: false,
error: null,
+ anchor: {
+ x: Math.round(rect.left),
+ y: Math.round(rect.bottom + 6),
+ },
})
}}
>
diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceSpaceRegionItem.tsx b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceSpaceRegionItem.tsx
index 8733fecee..1c7419cab 100644
--- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceSpaceRegionItem.tsx
+++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceSpaceRegionItem.tsx
@@ -1,5 +1,5 @@
import React from 'react'
-import { Folder } from 'lucide-react'
+import { Folder, LoaderCircle } from 'lucide-react'
import { useTranslation } from '@app/renderer/i18n'
import type { GitHubPullRequestSummary, GitWorktreeInfo } from '@shared/contracts/dto'
import type { WorkspaceSpaceRect } from '../../../types'
@@ -19,6 +19,7 @@ export function WorkspaceSpaceRegionItem({
isExplorerOpen,
isDragSurfaceSelectionMode,
githubPullRequestsEnabled,
+ busyOperationLabel,
editingSpaceId,
spaceRenameInputRef,
spaceRenameDraft,
@@ -43,6 +44,7 @@ export function WorkspaceSpaceRegionItem({
isExplorerOpen: boolean
isDragSurfaceSelectionMode: boolean
githubPullRequestsEnabled: boolean
+ busyOperationLabel: string | null
editingSpaceId: string | null
spaceRenameInputRef: React.RefObject
spaceRenameDraft: string
@@ -70,6 +72,7 @@ export function WorkspaceSpaceRegionItem({
spaceName: string
worktreePath: string
branchName: string
+ anchor: { x: number; y: number }
}) => void
onToggleExplorer?: (spaceId: string) => void
onOpenSpaceMenu?: (spaceId: string, anchor: { x: number; y: number }) => void
@@ -102,6 +105,7 @@ export function WorkspaceSpaceRegionItem({
'workspace-space-region',
space.parentSpaceId ? 'workspace-space-region--child' : null,
isSelected ? 'workspace-space-region--selected' : null,
+ busyOperationLabel ? 'workspace-space-region--busy' : null,
]
.filter(Boolean)
.join(' ')
@@ -281,11 +285,16 @@ export function WorkspaceSpaceRegionItem({
title={resolvedBranchBadge.title}
onClick={event => {
event.stopPropagation()
+ const rect = event.currentTarget.getBoundingClientRect()
onStartBranchRename({
spaceId: space.id,
spaceName: space.name,
worktreePath,
branchName,
+ anchor: {
+ x: Math.round(rect.left),
+ y: Math.round(rect.bottom + 6),
+ },
})
}}
>
@@ -338,6 +347,25 @@ export function WorkspaceSpaceRegionItem({
>
)}
+ {busyOperationLabel ? (
+ {
+ event.stopPropagation()
+ }}
+ onClick={event => {
+ event.stopPropagation()
+ }}
+ >
+
+
+ {busyOperationLabel}
+
+
+ ) : null}
)
}
diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceSpaceRegionsOverlay.tsx b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceSpaceRegionsOverlay.tsx
index d02f33648..153077402 100644
--- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceSpaceRegionsOverlay.tsx
+++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceSpaceRegionsOverlay.tsx
@@ -43,6 +43,7 @@ import { useWorkspaceWorktreeInfoByPath } from './WorkspaceSpaceRegionsOverlay.w
interface WorkspaceSpaceRegionsOverlayProps {
workspacePath: string
spaceVisuals: SpaceVisual[]
+ busyOperationBySpaceId?: ReadonlyMap
spaceFramePreview: ReadonlyMap | null
selectedSpaceIds: string[]
openExplorerSpaceId: string | null
@@ -65,6 +66,7 @@ interface WorkspaceSpaceRegionsOverlayProps {
export function WorkspaceSpaceRegionsOverlay({
workspacePath,
spaceVisuals,
+ busyOperationBySpaceId = new Map(),
spaceFramePreview,
selectedSpaceIds,
openExplorerSpaceId,
@@ -356,6 +358,11 @@ export function WorkspaceSpaceRegionsOverlay({
isExplorerOpen={isExplorerOpen}
isDragSurfaceSelectionMode={isDragSurfaceSelectionMode}
githubPullRequestsEnabled={githubPullRequestsEnabled}
+ busyOperationLabel={
+ branchRename?.spaceId === space.id && branchRename.isSubmitting
+ ? t('branchRenameDialog.renaming')
+ : (busyOperationBySpaceId.get(space.id) ?? null)
+ }
editingSpaceId={editingSpaceId}
spaceRenameInputRef={spaceRenameInputRef}
spaceRenameDraft={spaceRenameDraft}
@@ -370,7 +377,7 @@ export function WorkspaceSpaceRegionsOverlay({
resolvedChangedFileCount={resolvedChangedFileCount}
resolvedBranchBadge={resolvedBranchBadge}
resolvedPullRequestSummary={resolvedPullRequestSummary}
- onStartBranchRename={({ spaceId, spaceName, worktreePath, branchName }) => {
+ onStartBranchRename={({ spaceId, spaceName, worktreePath, branchName, anchor }) => {
setBranchRename({
spaceId,
spaceName,
@@ -380,6 +387,7 @@ export function WorkspaceSpaceRegionsOverlay({
nextName: branchName,
isSubmitting: false,
error: null,
+ anchor,
})
}}
onToggleExplorer={toggleExplorer}
diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/windows/SpaceTargetMountPickerWindow.tsx b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/windows/SpaceTargetMountPickerWindow.tsx
index 9f5104e1e..cf851feaf 100644
--- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/windows/SpaceTargetMountPickerWindow.tsx
+++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/windows/SpaceTargetMountPickerWindow.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useState, type Dispatch, type SetStateAction } from 'react'
import { useTranslation } from '@app/renderer/i18n'
+import { AnchoredOperationPopover } from '@app/renderer/components/AnchoredOperationPopover'
import type { ListWorkerEndpointsResult } from '@shared/contracts/dto'
import type { SpaceTargetMountPickerState } from '../types'
@@ -59,43 +60,26 @@ export function SpaceTargetMountPickerWindow({
const canConfirm = mounts.some(mount => mount.mountId === selectedMountId)
return (
- {
- onCancel()
- }}
+
- {
- event.stopPropagation()
- }}
- >
+
{t('spaceTargetMountPicker.title')}
{t('spaceTargetMountPicker.description')}
-
+
{mounts.map(mount => {
const endpointLabel = endpointLabelById[mount.endpointId] ?? mount.endpointId
return (
-
+
)
}
diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/windows/SpaceWorktreeMismatchDropWarningWindow.tsx b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/windows/SpaceWorktreeMismatchDropWarningWindow.tsx
index 7b8918226..08089a178 100644
--- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/windows/SpaceWorktreeMismatchDropWarningWindow.tsx
+++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/windows/SpaceWorktreeMismatchDropWarningWindow.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react'
-import { WarningDialog } from '@app/renderer/components/WarningDialog'
+import { AlertTriangle } from 'lucide-react'
+import { AnchoredOperationPopover } from '@app/renderer/components/AnchoredOperationPopover'
import { useTranslation } from '@app/renderer/i18n'
import { useAppStore } from '@app/renderer/shell/store/useAppStore'
import type { SpaceWorktreeMismatchDropWarningState } from '../types'
@@ -36,19 +37,45 @@ export function SpaceWorktreeMismatchDropWarningWindow({
}
return (
-
+
+
+
+
+
+
+
+
{t('spaceDropGuard.title', { name: warning.spaceName })}
+
{windowSummary}
+
+
+
+ {t('spaceDropGuard.description', {
+ badge: t('terminalNodeHeader.directoryMismatch'),
+ })}
+
+
+
+ {
+ setDontShowAgain(event.target.checked)
+ }}
+ />
+
+ {t('spaceDropGuard.dontShowAgain')}
+
+
+
+
{t('spaceDropGuard.move')}
- >
- }
- >
-
- {
- setDontShowAgain(event.target.checked)
- }}
- />
-
- {t('spaceDropGuard.dontShowAgain')}
-
-
-
+
+
+
)
}
diff --git a/src/contexts/worktree/presentation/renderer/windows/SpaceWorktreeGuardWindow.tsx b/src/contexts/worktree/presentation/renderer/windows/SpaceWorktreeGuardWindow.tsx
index c3be44553..25ebae763 100644
--- a/src/contexts/worktree/presentation/renderer/windows/SpaceWorktreeGuardWindow.tsx
+++ b/src/contexts/worktree/presentation/renderer/windows/SpaceWorktreeGuardWindow.tsx
@@ -1,5 +1,9 @@
import React from 'react'
-import { WarningDialog } from '@app/renderer/components/WarningDialog'
+import { AlertTriangle } from 'lucide-react'
+import {
+ AnchoredOperationPopover,
+ type AnchoredOperationPopoverAnchor,
+} from '@app/renderer/components/AnchoredOperationPopover'
import { useTranslation } from '@app/renderer/i18n'
export interface SpaceWorktreeGuardState {
@@ -13,11 +17,13 @@ export interface SpaceWorktreeGuardState {
}
export function SpaceWorktreeGuardWindow({
+ anchor = { x: 24, y: 24 },
guard,
onCancel,
onMarkMismatchAndContinue,
onCloseAllAndContinue,
}: {
+ anchor?: AnchoredOperationPopoverAnchor
guard: SpaceWorktreeGuardState | null
onCancel: () => void
onMarkMismatchAndContinue: () => void
@@ -35,16 +41,40 @@ export function SpaceWorktreeGuardWindow({
].join(' · ')
return (
-
+
+
+
+
+
+
+
+
{guard.pendingLabel}
+
{windowSummary}
+
+
+
+
+ {t('worktreeGuard.activeWindowsBound', { name: guard.spaceName })}
+
+
+ {guard.allowMarkMismatch
+ ? t('worktreeGuard.closeFirstOrMark')
+ : t('worktreeGuard.closeFirstOnly')}
+
+
+ {guard.error ? (
+ {guard.error}
+ ) : null}
+
+
{t('worktreeGuard.closeAllAndContinue')}
- >
- }
- >
-
- {guard.allowMarkMismatch
- ? t('worktreeGuard.closeFirstOrMark')
- : t('worktreeGuard.closeFirstOnly')}
-
-
- {guard.error ? (
-
{guard.error}
- ) : null}
-
+
+
+
)
}
diff --git a/src/contexts/worktree/presentation/renderer/windows/SpaceWorktreeWindow.tsx b/src/contexts/worktree/presentation/renderer/windows/SpaceWorktreeWindow.tsx
index 3ea2b5e39..95a30da47 100644
--- a/src/contexts/worktree/presentation/renderer/windows/SpaceWorktreeWindow.tsx
+++ b/src/contexts/worktree/presentation/renderer/windows/SpaceWorktreeWindow.tsx
@@ -1,19 +1,10 @@
import React, { useCallback, useEffect, useState } from 'react'
-import type { Node } from '@xyflow/react'
import { useTranslation } from '@app/renderer/i18n'
-import type { AgentSettings } from '@contexts/settings/domain/agentSettings'
-import type {
- TerminalNodeData,
- SpaceArchiveRecord,
- WorkspaceSpaceState,
-} from '@contexts/workspace/presentation/renderer/types'
-import type { ShowWorkspaceCanvasMessage } from '@contexts/workspace/presentation/renderer/components/workspaceCanvas/types'
import type { CreateGitWorktreeBranchMode, GitWorktreeInfo } from '@shared/contracts/dto'
import { isPathInsideOrEqual } from '@contexts/space/application/spaceBoundaryPolicy'
import { SpaceWorktreeGuardWindow, type SpaceWorktreeGuardState } from './SpaceWorktreeGuardWindow'
import { SpaceWorktreeWindowDialog } from './SpaceWorktreeWindowDialog'
import {
- type BlockingNodesSnapshot,
type BranchMode,
getBranchNameValidationError,
getWorktreeApiMethod,
@@ -30,39 +21,25 @@ import { useSpaceWorktreeSuggestNames } from './useSpaceWorktreeSuggestNames'
import { buildArchiveWarningMessage } from './spaceWorktreeWarnings'
import { toSpaceWorktreeErrorMessage } from './spaceWorktreeErrorMessage'
import { useSpaceWorktreeArchiveState } from './useSpaceWorktreeArchiveState'
+import type { SpaceWorktreeWindowProps } from './SpaceWorktreeWindow.types'
export function SpaceWorktreeWindow({
spaceId,
initialViewMode = 'create',
+ anchor = { x: 24, y: 24 },
+ operationPhase = 'draft',
spaces,
nodes,
workspacePath,
worktreesRoot,
agentSettings,
onClose,
+ onOperationPhaseChange,
onShowMessage,
onAppendSpaceArchiveRecord,
onUpdateSpaceDirectory,
getBlockingNodes,
closeNodesById,
-}: {
- spaceId: string | null
- initialViewMode?: 'create' | 'archive'
- spaces: WorkspaceSpaceState[]
- nodes: Node[]
- workspacePath: string
- worktreesRoot: string
- agentSettings: AgentSettings
- onClose: () => void
- onShowMessage?: ShowWorkspaceCanvasMessage
- onAppendSpaceArchiveRecord: (record: SpaceArchiveRecord) => void
- onUpdateSpaceDirectory: (
- spaceId: string,
- directoryPath: string,
- options?: UpdateSpaceDirectoryOptions,
- ) => void
- getBlockingNodes: (spaceId: string) => BlockingNodesSnapshot
- closeNodesById: (nodeIds: string[]) => Promise
-}): React.JSX.Element | null {
+}: SpaceWorktreeWindowProps): React.JSX.Element | null {
const { t } = useTranslation()
const [viewMode, setViewMode] = useState(initialViewMode)
const [branches, setBranches] = useState([])
@@ -297,12 +274,14 @@ export function SpaceWorktreeWindow({
}
setIsMutating(true)
+ onOperationPhaseChange?.('running')
let shouldClose = false
try {
await executePendingOperation(space.id, pending)
shouldClose = pending.kind === 'create' || pending.kind === 'archive'
} catch (operationError) {
setError(toSpaceWorktreeErrorMessage(operationError, t))
+ onOperationPhaseChange?.('error')
} finally {
setIsMutating(false)
}
@@ -311,7 +290,7 @@ export function SpaceWorktreeWindow({
onClose()
}
},
- [executePendingOperation, onClose, queueGuardIfNeeded, space, t],
+ [executePendingOperation, onClose, onOperationPhaseChange, queueGuardIfNeeded, space, t],
)
const { applyPendingWithMismatch, applyPendingByClosingAll } = useSpaceWorktreeGuardActions({
@@ -321,6 +300,7 @@ export function SpaceWorktreeWindow({
closeNodesById,
executePendingOperation,
onClose,
+ onOperationPhaseChange,
})
const handleSuggestNames = useSpaceWorktreeSuggestNames({
@@ -403,6 +383,7 @@ export function SpaceWorktreeWindow({
nodes,
onAppendSpaceArchiveRecord,
onClose,
+ onOperationPhaseChange,
ownWorktree,
setError,
setIsMutating,
@@ -428,51 +409,55 @@ export function SpaceWorktreeWindow({
}
return (
<>
-
- {guard ? (
+ {!isMutating && operationPhase !== 'running' && !guard ? (
+
+ ) : null}
+ {guard && !guard.isBusy && operationPhase !== 'running' ? (
{
setGuard(null)
diff --git a/src/contexts/worktree/presentation/renderer/windows/SpaceWorktreeWindow.types.ts b/src/contexts/worktree/presentation/renderer/windows/SpaceWorktreeWindow.types.ts
new file mode 100644
index 000000000..ac6d808d9
--- /dev/null
+++ b/src/contexts/worktree/presentation/renderer/windows/SpaceWorktreeWindow.types.ts
@@ -0,0 +1,33 @@
+import type { Node } from '@xyflow/react'
+import type { AnchoredOperationPopoverAnchor } from '@app/renderer/components/AnchoredOperationPopover'
+import type { AgentSettings } from '@contexts/settings/domain/agentSettings'
+import type {
+ SpaceArchiveRecord,
+ TerminalNodeData,
+ WorkspaceSpaceState,
+} from '@contexts/workspace/presentation/renderer/types'
+import type { ShowWorkspaceCanvasMessage } from '@contexts/workspace/presentation/renderer/components/workspaceCanvas/types'
+import type { BlockingNodesSnapshot, UpdateSpaceDirectoryOptions } from './spaceWorktree.shared'
+
+export interface SpaceWorktreeWindowProps {
+ spaceId: string | null
+ initialViewMode?: 'create' | 'archive'
+ anchor?: AnchoredOperationPopoverAnchor
+ operationPhase?: 'draft' | 'running' | 'error'
+ spaces: WorkspaceSpaceState[]
+ nodes: Node[]
+ workspacePath: string
+ worktreesRoot: string
+ agentSettings: AgentSettings
+ onClose: () => void
+ onOperationPhaseChange?: (phase: 'running' | 'error') => void
+ onShowMessage?: ShowWorkspaceCanvasMessage
+ onAppendSpaceArchiveRecord: (record: SpaceArchiveRecord) => void
+ onUpdateSpaceDirectory: (
+ spaceId: string,
+ directoryPath: string,
+ options?: UpdateSpaceDirectoryOptions,
+ ) => void
+ getBlockingNodes: (spaceId: string) => BlockingNodesSnapshot
+ closeNodesById: (nodeIds: string[]) => Promise
+}
diff --git a/src/contexts/worktree/presentation/renderer/windows/SpaceWorktreeWindowDialog.tsx b/src/contexts/worktree/presentation/renderer/windows/SpaceWorktreeWindowDialog.tsx
index 9c820bfc2..2e8dc1d91 100644
--- a/src/contexts/worktree/presentation/renderer/windows/SpaceWorktreeWindowDialog.tsx
+++ b/src/contexts/worktree/presentation/renderer/windows/SpaceWorktreeWindowDialog.tsx
@@ -1,5 +1,9 @@
import React from 'react'
import { GitBranch, X } from 'lucide-react'
+import {
+ AnchoredOperationPopover,
+ type AnchoredOperationPopoverAnchor,
+} from '@app/renderer/components/AnchoredOperationPopover'
import { useTranslation } from '@app/renderer/i18n'
import type { GitWorktreeInfo } from '@shared/contracts/dto'
import type { WorkspaceSpaceState } from '@contexts/workspace/presentation/renderer/types'
@@ -7,6 +11,7 @@ import { SpaceWorktreePanels } from './SpaceWorktreePanels'
import type { BranchMode, SpaceWorktreeViewMode } from './spaceWorktree.shared'
export function SpaceWorktreeWindowDialog({
+ anchor,
space,
isSpaceOnWorkspaceRoot,
currentWorktree,
@@ -49,6 +54,7 @@ export function SpaceWorktreeWindowDialog({
onArchive,
onCloseOnly,
}: {
+ anchor: AnchoredOperationPopoverAnchor
space: WorkspaceSpaceState
isSpaceOnWorkspaceRoot: boolean
currentWorktree: GitWorktreeInfo | null
@@ -124,19 +130,18 @@ export function SpaceWorktreeWindowDialog({
.join(' · ')
return (
- {
- if (isBusy || guardIsBusy) {
- return
- }
-
- onBackdropClose()
- }}
+ dismissDisabled={isBusy || guardIsBusy}
+ estimatedHeight={viewMode === 'archive' ? 500 : 420}
+ onDismiss={onBackdropClose}
+ testId="space-worktree-popover"
>
{
- event.stopPropagation()
- }}
>