Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 17 additions & 1 deletion docs/ui/WINDOW_UI_STANDARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -25,6 +36,7 @@
### 2.2 Surface
- 16px 圆角
- 边框/底色/阴影必须跟随主题 token(例如 `--cove-window-surface`、`--cove-surface*`、`--cove-border*`、`--cove-shadow-color-*`)
- 上下文操作浮层使用更紧凑的圆角、内边距与菜单同源的材质,避免呈现为缩小版模态框。

### 2.3 Input
- 10px 圆角
Expand All @@ -39,10 +51,11 @@

## 3) 交互一致性

- 点击遮罩关闭窗口(危险动作弹窗除外时需二次确认
- 模态窗口点击遮罩关闭;上下文操作浮层点击外部或按 Esc 关闭(提交进行中除外
- 主按钮始终放最右
- 错误信息用统一 error block 样式
- 小窗优先简洁,复杂编辑由完整弹窗承载
- 动效应短促且只表达空间关系;必须支持 `prefers-reduced-motion`,不得用动效延迟可操作状态

## 4) 实现约定(当前)

Expand All @@ -66,3 +79,6 @@
- 是否使用统一按钮语义(ghost/secondary/primary/danger)
- 是否提供稳定 `data-testid`
- 是否保证键盘与关闭行为一致(Esc / blur / confirm)
- 是否根据任务范围正确选择 Popover、局部忙碌状态或 Modal
- 是否让异步 owner 独立于浮层生命周期,并将忙碌状态限制在目标 Space
- 是否验证亮色、暗色与减少动态效果模式
74 changes: 74 additions & 0 deletions src/app/renderer/components/AnchoredOperationPopover.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<TransientLayerOwnerProvider value={ownerId}>
<ViewportMenuSurface
open
aria-label={ariaLabel}
aria-modal="false"
className={
className ? `anchored-operation-popover ${className}` : 'anchored-operation-popover'
}
data-testid={testId}
dismissOnEscape={!dismissDisabled}
dismissOnPointerDownOutside={!dismissDisabled}
dismissIgnoreTarget={target => 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}
</ViewportMenuSurface>
</TransientLayerOwnerProvider>
)
}
8 changes: 8 additions & 0 deletions src/app/renderer/components/CoveSelect.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 => {
Expand Down
27 changes: 27 additions & 0 deletions src/app/renderer/components/TransientLayerOwnerContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React from 'react'

export const TRANSIENT_LAYER_OWNER_ATTRIBUTE = 'data-cove-transient-layer-owner'

const TransientLayerOwnerContext = React.createContext<string | null>(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
}
27 changes: 25 additions & 2 deletions src/app/renderer/components/ViewportMenuSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ export interface ViewportMenuSurfaceProps extends Omit<
dismissOnPointerDownOutside?: boolean
dismissOnEscape?: boolean
dismissIgnoreRefs?: Array<React.RefObject<HTMLElement | null>>
dismissIgnoreTarget?: (target: EventTarget | null) => boolean
stopEventPropagation?: boolean
waitForMeasurement?: boolean
}

function assignRef<T>(ref: React.ForwardedRef<T>, value: T): void {
Expand Down Expand Up @@ -66,7 +68,9 @@ export const ViewportMenuSurface = React.forwardRef<HTMLDivElement, ViewportMenu
dismissOnPointerDownOutside = false,
dismissOnEscape = false,
dismissIgnoreRefs = [],
dismissIgnoreTarget,
stopEventPropagation = true,
waitForMeasurement = false,
style,
onMouseDown,
onClick,
Expand Down Expand Up @@ -146,7 +150,10 @@ export const ViewportMenuSurface = React.forwardRef<HTMLDivElement, ViewportMenu
return true
}

return dismissIgnoreRefs.some(ref => ref.current?.contains(target) ?? false)
return (
dismissIgnoreRefs.some(ref => ref.current?.contains(target) ?? false) ||
dismissIgnoreTarget?.(target) === true
)
}

const handlePointerDown = (event: PointerEvent): void => {
Expand All @@ -166,6 +173,10 @@ export const ViewportMenuSurface = React.forwardRef<HTMLDivElement, ViewportMenu
return
}

if (dismissIgnoreTarget?.(event.target) || dismissIgnoreTarget?.(document.activeElement)) {
return
}

onDismiss()
}

Expand All @@ -176,7 +187,14 @@ export const ViewportMenuSurface = React.forwardRef<HTMLDivElement, ViewportMenu
document.removeEventListener('pointerdown', handlePointerDown, true)
window.removeEventListener('keydown', handleKeyDown, true)
}
}, [dismissIgnoreRefs, dismissOnEscape, dismissOnPointerDownOutside, onDismiss, open])
}, [
dismissIgnoreRefs,
dismissIgnoreTarget,
dismissOnEscape,
dismissOnPointerDownOutside,
onDismiss,
open,
])

const resolvedPosition = React.useMemo(() => {
if (placement.type === 'absolute') {
Expand All @@ -203,14 +221,19 @@ export const ViewportMenuSurface = React.forwardRef<HTMLDivElement, ViewportMenu
return null
}

const isMeasurementReady = placement.type !== 'point' || measuredSize !== null

return createPortal(
<div
{...rest}
ref={setRefs}
data-viewport-menu-measured={waitForMeasurement ? String(isMeasurementReady) : undefined}
style={{
...style,
top: resolvedPosition.top,
left: resolvedPosition.left,
visibility:
waitForMeasurement && !isMeasurementReady ? 'hidden' : (style?.visibility ?? undefined),
}}
onMouseDown={event => {
if (stopEventPropagation) {
Expand Down
2 changes: 2 additions & 0 deletions src/app/renderer/i18n/locales/en.worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions src/app/renderer/i18n/locales/zh-CN.worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const zhCNWorktree = {
branchPlaceholder: '例如:space/infra-core',
branchHasWorktree: '已有 worktree',
createAndBind: '创建并绑定',
creatingWorktree: '正在创建 worktree…',
archivingSpace: '正在归档 Space…',
deleteWorktree: '删除 worktree',
deleteWorktreeHelp: '从磁盘移除该 worktree 目录。',
deleteBranch: '删除分支',
Expand Down
18 changes: 9 additions & 9 deletions src/app/renderer/shell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<SettingsPageId | null>(null)
const controlCenterButtonRef = useRef<HTMLButtonElement | null>(null)
const {
Expand All @@ -151,6 +152,7 @@ export default function App(): React.JSX.Element {
isWorkspaceSearchOpen,
isSpaceArchivesOpen,
isAddProjectWizardOpen,
addProjectWizardAnchor,
hasBlockingOverlay,
toggleCommandCenter,
closeCommandCenter,
Expand Down Expand Up @@ -231,7 +233,7 @@ export default function App(): React.JSX.Element {

useEffect(() => {
if (!isSettingsOpen) {
setIsFocusNodeTargetZoomPreviewing(false)
setFocusNodeZoomPreviewing(false)
}
}, [isSettingsOpen])

Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading