Merge remote-tracking branch 'origin/master' into worktree-session-hover-menu-fix

# Conflicts:
#	apps/web/tests/workspace-management.e2e.ts
#	packages/client/ui-workspace/README.i18n.yaml
This commit is contained in:
creatixchu
2026-07-31 17:56:06 +08:00
72 changed files with 612 additions and 491 deletions
@@ -1,11 +1,13 @@
/**
* The workspace/session browsing region filling the sidebar shell's
* `sidebar.workspaces` hole: section header (title + group-by + new
* `sidebar.workspaces` hole: section header (title + group-by + add
* workspace), search, the grouped tree or flat list, and the workspace
* dialogs. Wide state renders the full browser; rail state renders the two
* region icons (search / new workspace), each requesting shell expansion
* through the owner share. The picker menu and create dialogs live in
* WorkspacePicker (same package — direct composition, no slot between them).
* region icons (search / add workspace), each requesting shell expansion
* through the owner share. Adding is the header button's one action, so it
* raises the directory flow with no menu in between; the flow and its error
* dialog live in WorkspacePicker (same package — direct composition, no slot
* between them).
*/
import { useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
@@ -20,7 +22,7 @@ import type { WorkspaceBrowserProps } from './contract/slots.ts'
import type { SessionNode } from './tree.ts'
import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from './tree.ts'
import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './rows/Rows.tsx'
import { WorkspaceCreateFlow } from './WorkspacePicker.tsx'
import { WorkspacePickFlow } from './WorkspacePicker.tsx'
import css from './WorkspaceBrowser.module.css'
/**
@@ -358,6 +360,9 @@ export function WorkspaceBrowser({
}: WorkspaceBrowserProps) {
const workspaces = useWorkspaces(state => state.items)
const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds)
// Live occupancy of this surface's directory-flow hole (the same source the
// flow reads): a composition without a picking affordance can add nothing.
const directoryFlowAvailable = useDirectoryFlow(occupied => occupied)
const groupBy = useStore(s => s.groupBy)
// The query outlives the tree and the input (both wide-only) so collapsing
// does not silently drop an in-progress filter.
@@ -541,21 +546,26 @@ export function WorkspaceBrowser({
</span>
)}
{wide && <GroupByMenu groupBy={groupBy} onPick={(mode) => { actions.setGroupBy(mode) }} t={t} />}
<Tooltip label={t('workspace.new')} disabled={wide}>
<button
ref={wsPlusRef}
type="button"
className={css.iconButton}
aria-label={t('create.confirm')}
onClick={() => {
setWsPickerOpen(v => !v)
}}
>
<IconProjectAddOutline16 size={wide ? 16 : 18} />
</button>
</Tooltip>
{/* Picker menu + create dialogs (same package — direct composition). */}
<WorkspaceCreateFlow
{/* Adding is the button's one action, so a composition with no
picking affordance has nothing to offer here: the region hides the
button rather than leaving a dead one in the header. */}
{directoryFlowAvailable && (
<Tooltip label={t('workspace.add')} disabled={wide}>
<button
ref={wsPlusRef}
type="button"
className={css.iconButton}
aria-label={t('workspace.add')}
onClick={() => {
setWsPickerOpen(v => !v)
}}
>
<IconProjectAddOutline16 size={wide ? 16 : 18} />
</button>
</Tooltip>
)}
{/* Add flow + its error dialog (same package — direct composition). */}
<WorkspacePickFlow
t={t}
open={wsPickerOpen}
anchorRef={wsPlusRef}
@@ -563,7 +573,7 @@ export function WorkspaceBrowser({
createWorkspace={createWorkspace}
useDirectoryFlow={useDirectoryFlow}
renderDirectoryFlow={owner => renderSlot('sidebar.workspaces.directoryFlow', owner)}
createOnly
addOnly
side="right"
onPick={(workspaceId) => {
setWsPickerOpen(false)
@@ -1,35 +1,10 @@
/* Modal form styles mirror the empty state's path/create modals (same figma
* dialog family: field h44, r22, hairline border, pad 14/7) so the two
* entries stay visually identical. */
.modalInput {
box-sizing: border-box;
width: 100%;
height: 44px;
padding: 7px 14px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 22px;
outline: none;
background: transparent;
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
.modalInput::placeholder {
color: var(--dsw-alias-label-caption);
}
.modalInput:disabled {
color: var(--dsw-alias-label-dimmed);
}
/* The adoption error dialog's footer and message styles; the dialog itself is
* the shared Modal (same figma dialog family as the browser's own dialogs). */
.modalAction {
min-width: 72px;
}
.modalError,
.modalStatus,
.menuStatus {
margin-top: 8px;
font-size: 12px;
@@ -40,7 +15,6 @@
color: var(--dsw-alias-state-error-primary);
}
.modalStatus,
.menuStatus {
color: var(--dsw-alias-label-secondary);
}
@@ -1,13 +1,15 @@
/**
* Workspace pick/create flow. WorkspaceCreateFlow is the reusable core
* (menu + path/create dialogs) consumed directly by WorkspaceBrowser (same
* package) and wrapped by WorkspacePicker for the conversation empty-state
* slot registration. Directory picking itself lives in the composed flow
* package's slot occupant (see the contract module doc): this core only
* opens the flow, adopts the picked path, and owns the error surface.
* Workspace pick/add flow. WorkspacePickFlow is the reusable core (menu +
* path error dialog) consumed directly by WorkspaceBrowser (same package) and
* wrapped by WorkspacePicker for the conversation empty-state slot
* registration. Directory picking itself lives in the composed flow package's
* slot occupant (see the contract module doc): this core only opens the flow,
* adopts the picked path, and owns the error surface. Adding a workspace has
* exactly one route — pick a host directory, new or existing — because the
* occupant's own create-folder affordance already covers creating one.
*/
import type { ReactNode, RefObject } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import {
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
@@ -19,13 +21,10 @@ import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from './contract/slots.ts'
import css from './WorkspacePicker.module.css'
const OPEN_LOCAL_FOLDER = '::open-local-folder'
const CREATE_NEW = '::create-new'
type ModalKind = 'create' | 'folder-error' | null
const ADD_WORKSPACE = '::add-workspace'
/** Core flow props: the owner supplies popover control and pick semantics. */
export interface WorkspaceCreateFlowProps {
export interface WorkspacePickFlowProps {
/** The standard locale seat, forwarded by whichever slot entry hosts the flow. */
t: WorkspacePickerProps['t']
/** Popover visibility (anchor button toggle state, owner-local). */
@@ -34,9 +33,9 @@ export interface WorkspaceCreateFlowProps {
anchorRef?: RefObject<HTMLElement | null> | undefined
/** Selector hook over the workspace list (framework standard hook). */
useWorkspaces: <S>(selector: (state: WorkspaceListState) => S) => S
/** Create or adopt a real Host Workspace. */
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Bound occupancy selector hook for this surface's directory-flow hole (empty hides the local-folder entry). */
/** Adopt a picked host directory as a real Workspace. */
createWorkspace: (input: { path: string }) => Promise<WorkspaceView>
/** Bound occupancy selector hook for this surface's directory-flow hole (empty leaves the surface with no add action). */
useDirectoryFlow: SnapshotSelectorHook<boolean>
/** Render this surface's directory-flow hole with the owner conversation (the entry's narrowed renderSlot). */
renderDirectoryFlow: (owner: DirectoryFlowOwnerProps) => ReactNode
@@ -44,8 +43,8 @@ export interface WorkspaceCreateFlowProps {
onPick: (workspaceId: WorkspaceId) => void
/** Close the popover (outside click / Escape / post-pick). */
onClose: () => void
/** Only show create actions (open folder / create new), hide existing workspaces. */
createOnly?: boolean
/** Only offer the add action, hide existing workspaces. */
addOnly?: boolean
/** Menu opening direction relative to the anchor. */
side?: 'bottom' | 'top' | 'right'
/** Currently active workspace (trailing check in the picker list). */
@@ -53,11 +52,11 @@ export interface WorkspaceCreateFlowProps {
}
/**
* Render the pick menu plus the two create dialogs.
* Render the pick menu plus the adoption error dialog.
* @param props - owner-controlled flow props.
* @returns menu + dialog elements.
*/
export function WorkspaceCreateFlow({
export function WorkspacePickFlow({
t,
open,
anchorRef,
@@ -67,32 +66,26 @@ export function WorkspaceCreateFlow({
renderDirectoryFlow,
onPick,
onClose,
createOnly = false,
addOnly = false,
side = 'bottom',
selectedId,
}: WorkspaceCreateFlowProps) {
}: WorkspacePickFlowProps) {
const workspaceSnapshot = useWorkspaces(state => state)
const workspaces = workspaceSnapshot.items
const getAnchorRect = useCallback(
() => anchorRef?.current?.getBoundingClientRect() ?? null,
[anchorRef],
)
const [modalKind, setModalKind] = useState<ModalKind>(null)
const [workspaceName, setWorkspaceName] = useState('')
const [creating, setCreating] = useState(false)
const [errorOpen, setErrorOpen] = useState(false)
const [modalError, setModalError] = useState<string | null>(null)
const [flowOpen, setFlowOpen] = useState(false)
const [pickingFolder, setPickingFolder] = useState(false)
const [folderConflict, setFolderConflict] = useState(false)
const composingRef = useRef(false)
// One picking interaction at a time: while the flow is open (native chooser
// pending, browse dialog up) or its pick is being adopted, every other
// menu action stays disabled — a late outcome must not race a concurrent
// selection or creation.
// selection or adoption.
const flowBusy = flowOpen || pickingFolder
const normalizedWorkspaceName = workspaceName.trim()
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
// The occupied hole gates the picking affordance: with no composed flow the
// entry simply is not there (the seam's documented no-flow default). The
@@ -107,27 +100,27 @@ export function WorkspaceCreateFlow({
useEffect(() => {
if (flowOpen && !flowAvailable) setFlowOpen(false)
}, [flowOpen, flowAvailable])
const createEntries: MenuEntry[] = [
...(flowAvailable
? [{ id: OPEN_LOCAL_FOLDER, label: t('menu.openFolder'), icon: <IconFolderClose16 size={16} />, disabled: flowBusy }]
: []),
{ id: CREATE_NEW, label: t('menu.createWorkspace'), icon: <IconPlusOutline16 size={16} />, disabled: flowBusy },
]
// With workspaces listed, the create actions pin below the scroll region
// (divider + always visible); otherwise they ARE the menu.
const pinCreate = !createOnly && workspaces.length > 0
const items: MenuEntry[] = pinCreate
const addEntries: MenuEntry[] = flowAvailable
? [{ id: ADD_WORKSPACE, label: t('menu.addWorkspace'), icon: <IconPlusOutline16 size={16} />, disabled: flowBusy }]
: []
// With workspaces listed, the add action pins below the scroll region
// (divider + always visible); otherwise it IS the menu.
const pinAdd = !addOnly && workspaces.length > 0
const items: MenuEntry[] = pinAdd
? workspaces.map(workspace => ({
id: workspace.workspaceId,
label: workspace.title,
icon: <IconFolderClose16 size={16} />,
disabled: flowBusy,
}))
: createEntries
: addEntries
// Nothing listed and nothing to add with (a composition that mounts this
// package without any directory-picker): an empty popover would claim a
// choice that does not exist, so the anchor gesture shows nothing at all.
const menuIsEmpty = items.length === 0
const closeModal = (): void => {
if (creating) return
setModalKind(null)
setErrorOpen(false)
setModalError(null)
}
@@ -143,16 +136,32 @@ export function WorkspaceCreateFlow({
)
setModalError(reason instanceof Error ? reason.message : String(reason))
setFlowOpen(false)
setModalKind('folder-error')
setErrorOpen(true)
})
const openLocalFolder = (): void => {
const openDirectoryFlow = useCallback((): void => {
onClose()
setModalKind(null)
setErrorOpen(false)
setModalError(null)
setFolderConflict(false)
setFlowOpen(true)
}
}, [onClose])
// A menu exists to disambiguate between targets. With no workspaces listed
// and the add action the only entry left, the anchor gesture IS that action:
// a one-row popover would cost a click and offer nothing to choose between.
// The owner's open request is consumed the same way selecting the entry
// would consume it (close the popover, raise the flow). An empty list is
// only final once the baseline lands — until then the menu stays up with its
// loading status instead of jumping into a flow the arriving list would have
// made unnecessary; the add-only surface lists nothing and never waits.
const listSettled = addOnly || workspaceSnapshot.phase === 'ready'
const addIsTheOnlyEntry = !pinAdd && listSettled && addEntries.length === 1
// `flowBusy` gates this exactly as it disables the equivalent menu entry: a
// pick still being adopted owns the surface until it settles.
useEffect(() => {
if (open && addIsTheOnlyEntry && !flowBusy) openDirectoryFlow()
}, [open, addIsTheOnlyEntry, flowBusy, openDirectoryFlow])
/** Owner side of the flow conversation: adopt keeps the flow open (busy) until the Host answers. */
const flowOwner: DirectoryFlowOwnerProps = {
@@ -167,53 +176,25 @@ export function WorkspaceCreateFlow({
setFlowOpen(false)
setFolderConflict(false)
setModalError(message)
setModalKind('folder-error')
setErrorOpen(true)
},
}
const handleSelect = (id: string): void => {
if (id === OPEN_LOCAL_FOLDER) {
openLocalFolder()
return
}
if (id === CREATE_NEW) {
onClose()
setWorkspaceName('')
setModalError(null)
setModalKind('create')
if (id === ADD_WORKSPACE) {
openDirectoryFlow()
return
}
onPick(id as WorkspaceId)
}
const create = (input: { name: string } | { path: string }): void => {
if (creating) return
setCreating(true)
setModalError(null)
void createWorkspace(input).then((workspace) => {
setCreating(false)
setModalKind(null)
onPick(workspace.workspaceId)
}).catch((reason: unknown) => {
const message = reason instanceof Error ? reason.message : String(reason)
setModalError(`Workspace creation failed: ${message}`)
setCreating(false)
})
}
const confirmCreate = (): void => {
if (normalizedWorkspaceName !== '' && !duplicateWorkspaceName) {
create({ name: normalizedWorkspaceName })
}
}
return (
<>
<Menu
open={open}
open={open && !addIsTheOnlyEntry && !menuIsEmpty}
anchor={null}
items={items}
{...pinCreate ? { footer: createEntries } : {}}
{...pinAdd ? { footer: addEntries } : {}}
selectedId={selectedId}
onSelect={handleSelect}
onClose={onClose}
@@ -221,10 +202,10 @@ export function WorkspaceCreateFlow({
portal
getAnchorRect={getAnchorRect}
/>
{open && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">{t('picker.loading')}</div>}
{open && !addIsTheOnlyEntry && !menuIsEmpty && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">{t('picker.loading')}</div>}
{renderDirectoryFlow(flowOwner)}
<Modal
open={modalKind === 'folder-error'}
open={errorOpen}
onClose={closeModal}
closeLabel={t('close')}
title={folderConflict ? t('conflict.title') : t('folderError.title')}
@@ -233,7 +214,7 @@ export function WorkspaceCreateFlow({
<Button variant="outline" className={css.modalAction} onClick={closeModal}>{t('cancel')}</Button>
{/* Retrying needs an occupant to serve the flow; without one the
* button would open a flow nobody can answer or cancel. */}
<Button variant="primary" className={css.modalAction} disabled={!flowAvailable} onClick={openLocalFolder}>{t('folderError.retry')}</Button>
<Button variant="primary" className={css.modalAction} disabled={!flowAvailable} onClick={openDirectoryFlow}>{t('folderError.retry')}</Button>
</>
)}
>
@@ -243,49 +224,6 @@ export function WorkspaceCreateFlow({
: modalError}
</div>
</Modal>
<Modal
open={modalKind === 'create'}
onClose={closeModal}
closeLabel={t('close')}
title={t('menu.createWorkspace')}
description={t('create.desc')}
footer={(
<>
<Button variant="outline" className={css.modalAction} disabled={creating} onClick={closeModal}>{t('cancel')}</Button>
<Button
variant="primary"
className={css.modalAction}
disabled={creating || normalizedWorkspaceName === '' || duplicateWorkspaceName}
onClick={confirmCreate}
>
{t('create.confirm')}
</Button>
</>
)}
>
<input
className={css.modalInput}
value={workspaceName}
placeholder={t('field.workspaceName')}
aria-label={t('create.name.aria')}
autoFocus
disabled={creating}
onChange={(event) => { setWorkspaceName(event.target.value); setModalError(null) }}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
onKeyDown={(event) => {
if (event.key === 'Enter' && !composingRef.current) {
event.preventDefault()
confirmCreate()
}
}}
/>
{creating && <div className={css.modalStatus} role="status">{t('create.pending')}</div>}
{duplicateWorkspaceName && (
<div className={css.modalError} role="alert">{t('conflict.named', { name: normalizedWorkspaceName })}</div>
)}
{modalError !== null && <div className={css.modalError} role="alert">{modalError}</div>}
</Modal>
</>
)
}
@@ -309,7 +247,7 @@ export function WorkspacePicker({
t,
}: WorkspacePickerProps) {
return (
<WorkspaceCreateFlow
<WorkspacePickFlow
t={t}
open={open}
anchorRef={anchorRef}
@@ -5,16 +5,19 @@
* the whole browsing region (section header, search, grouped/flat session
* list, workspace dialogs). It registers this package's viewing store and
* consumes the shell's two-fact owner share (wide / expandSidebar).
* - WorkspacePicker fills the conversation empty-state hole (menu +
* create dialogs shared with the browser).
* - WorkspacePicker fills the conversation empty-state hole (menu + error
* dialog shared with the browser).
*
* Each registration also declares one **directory-flow hole** (`single`
* kind): the slot a composed picker package's client half fills with its
* picking interaction — a renderless native-chooser driver or an in-app
* browsing dialog. ui-workspace owns the trigger (the "Open local folder…"
* menu entry, shown only while the hole is occupied) and the adoption
* browsing dialog. ui-workspace owns the trigger (the "Add workspace…"
* entry, present only while the hole is occupied) and the adoption
* semantics (`createWorkspace({ path })`, the conflict/error dialog, Choose
* again); the occupant owns everything between `open` and the picked path.
* again); the occupant owns everything between `open` and the picked path,
* including creating a new directory to hand back. That occupant-owned
* creation is why adding a workspace has a single route: an unoccupied hole
* leaves the surface with no add affordance at all.
* Two holes exist because the two menu surfaces are independent slot entries
* and a hole has exactly one declaring entry — they carry the same owner
* contract and the same occupant.
@@ -65,7 +68,7 @@ export type DirectoryFlowSlotName =
* Directory-picking share both trigger surfaces consume. Occupancy rides the
* inject face's reserved `hooks` compartment: the renderer binds the source
* into the `useDirectoryFlow` selector hook, so an empty hole hides the
* "Open local folder…" entry reactively and the surface withdraws an open
* "Add workspace…" entry reactively and the surface withdraws an open
* flow whose occupant unloaded mid-interaction (nobody is left to cancel).
*/
export type DirectoryPickingInjected = {
@@ -125,8 +128,8 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
* the Host response/changed frame; failures leave the order unchanged.
*/
insertSessionBefore: (workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId) => Promise<void>
/** Explicitly create or adopt a real Workspace before targeting a Session. */
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Adopt a picked host directory as a real Workspace before targeting a Session. */
createWorkspace: (input: { path: string }) => Promise<WorkspaceView>
}
/** Full browser props: shell owner share + viewing store + injected actions + the locale seat. */
@@ -144,8 +147,8 @@ export type WorkspaceBrowserProps =
* supplies the implicit index signature required by the registry.
*/
export type WorkspacePickerInjected = DirectoryPickingInjected & {
/** Explicitly create or adopt a real Workspace before targeting a Session. */
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Adopt a picked host directory as a real Workspace before targeting a Session. */
createWorkspace: (input: { path: string }) => Promise<WorkspaceView>
}
/**
@@ -1,6 +1,6 @@
/**
* `workspace` namespace dictionaries: the browsing region (section header,
* search, tree rows, dialogs) and the pick/create flow. Runtime failure
* search, tree rows, dialogs) and the pick/add flow. Runtime failure
* messages (wire error strings) pass through untranslated by policy.
*/
@@ -15,7 +15,7 @@ export const zh = {
'groupBy.flat': '单列表',
'empty.none': '暂无会话',
'empty.noMatches': '无匹配结果',
'workspace.new': '新建工作区',
'workspace.add': '添加工作区',
'search.sessions.aria': '搜索会话',
'search.placeholder': '搜索名称、关键词…',
'search.clear': '清除搜索',
@@ -24,18 +24,13 @@ export const zh = {
'search.unavailable': '内容搜索暂不可用,仅显示名称匹配。',
'search.noMatches': '无匹配会话',
'search.hasMore': '仅显示前 {n} 条结果,请缩小搜索范围。',
'menu.openFolder': '打开本地文件夹…',
'menu.createWorkspace': '新建工作区',
'menu.addWorkspace': '添加工作区…',
'picker.loading': '正在加载工作区…',
'conflict.title': '已存在同名工作区',
'conflict.hint': '请选择其他名称的文件夹。',
'conflict.named': '已存在名为“{name}”的工作区。',
'folderError.title': '无法打开文件夹',
'folderError.retry': '重新选择',
'create.confirm': '创建工作区',
'create.desc': '该名称将同时用于工作区及其新文件夹。',
'create.name.aria': '新工作区名称',
'create.pending': '正在创建工作区…',
'rename': '重命名',
'rename.workspace.title': '重命名工作区',
'rename.session.title': '重命名会话',
@@ -79,7 +74,7 @@ export const en = {
'groupBy.flat': 'In one list',
'empty.none': 'No sessions yet',
'empty.noMatches': 'No matches',
'workspace.new': 'New Workspace',
'workspace.add': 'Add workspace',
'search.sessions.aria': 'Search sessions',
'search.placeholder': 'Search name, keywords...',
'search.clear': 'Clear search',
@@ -88,18 +83,13 @@ export const en = {
'search.unavailable': 'Content search is temporarily unavailable. Showing name matches.',
'search.noMatches': 'No matching sessions',
'search.hasMore': 'Showing the first {n} results. Narrow your search.',
'menu.openFolder': 'Open local folder…',
'menu.createWorkspace': 'Create a new workspace',
'menu.addWorkspace': 'Add workspace…',
'picker.loading': 'Loading workspaces…',
'conflict.title': 'A workspace with this name already exists',
'conflict.hint': 'Choose a folder with a different name.',
'conflict.named': 'A workspace named “{name}” already exists.',
'folderError.title': 'Couldnt open folder',
'folderError.retry': 'Choose again',
'create.confirm': 'Create workspace',
'create.desc': 'The name is used for both the workspace and its new folder.',
'create.name.aria': 'New workspace name',
'create.pending': 'Creating workspace…',
'rename': 'Rename',
'rename.workspace.title': 'Rename workspace',
'rename.session.title': 'Rename session',