fix(web): address the review round on the one-route add flow

- lifecycle-chrome's second scaffold staged its workspace under the OUTER
  scaffold's temp root, coupling two supposedly independent worlds and
  leaving the aria scrub root wrong; it now uses its own workspaceCwd.
- The direct-open path now carries the same `flowBusy` gate that disables
  the equivalent menu entry, so an occupant re-registering mid-adoption
  cannot raise a second flow.
- A composition with no directory-picker no longer opens a zero-entry
  popover on the hero anchor: with nothing to pick and nothing to add,
  the gesture shows nothing. Both behaviors gain a unit test.
- Brought three partially superseded Agent Notes current (the native
  picker, the workspace UI product flow, and the sidebar browsing split),
  cross-linked to this decision, both languages re-recorded.
- Corrected this Note's own Testing section: the shared e2e helper stages
  and adopts its directory, it does not create one in-dialog — only
  workspace-management does. Named the client-seam and CLI-README residue
  in the follow-up TODO alongside the wire branch.
This commit is contained in:
creatixchu
2026-07-31 16:23:49 +08:00
parent d6231007af
commit 6184ba35ca
16 changed files with 83 additions and 35 deletions

View File

@@ -114,6 +114,10 @@ export function WorkspacePickFlow({
disabled: flowBusy,
}))
: 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 => {
setErrorOpen(false)
@@ -153,9 +157,11 @@ export function WorkspacePickFlow({
// 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) openDirectoryFlow()
}, [open, addIsTheOnlyEntry, openDirectoryFlow])
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 = {
@@ -185,7 +191,7 @@ export function WorkspacePickFlow({
return (
<>
<Menu
open={open && !addIsTheOnlyEntry}
open={open && !addIsTheOnlyEntry && !menuIsEmpty}
anchor={null}
items={items}
{...pinAdd ? { footer: addEntries } : {}}
@@ -196,7 +202,7 @@ export function WorkspacePickFlow({
portal
getAnchorRect={getAnchorRect}
/>
{open && !addIsTheOnlyEntry && 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={errorOpen}

View File

@@ -253,6 +253,34 @@ describe('WorkspacePicker', () => {
expect(screen.getByRole('menuitem', { name: '添加工作区…' })).toBeTruthy()
})
it('shows no popover at all when nothing is listed and nothing can be added', () => {
// A composition mounting this package without any directory-picker: the
// hero anchor has neither a Workspace to pick nor a way to add one, so it
// must not claim a choice with an empty menu.
const b = mount([], vi.fn(), occupancySource(false))
expect(screen.queryByRole('menu')).toBeNull()
expect(screen.queryByTestId('directory-flow')).toBeNull()
expect(b.createWorkspace).not.toHaveBeenCalled()
})
it('holds the anchor gesture while an adoption is still settling', async () => {
// The auto-open path obeys the same busy rule as the disabled menu entry:
// an occupant that re-registers mid-adoption must not raise a second flow.
let resolve!: (workspace: WorkspaceView) => void
const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
const created = workspace('adopted')
const b = mount([workspace('alpha', 'Alpha')], vi.fn(() => pending))
chooseAdd()
act(() => { b.probe.owner!.onPicked('/tmp/project') })
expect(b.probe.owner!.busy).toBe(true)
// The list empties under the still-settling adoption (the workspace was
// deleted elsewhere), which would otherwise make add the only entry.
act(() => { b.rerenderItems([]) })
expect(b.createWorkspace).toHaveBeenCalledTimes(1)
await act(async () => { resolve(created); await pending })
expect(b.probe.owner!.busy).toBe(false)
})
it('hides the add entry while the directory-flow hole is empty', () => {
mount([workspace('alpha', 'Alpha')], vi.fn(), occupancySource(false))
expect(screen.getByRole('menuitem', { name: 'Alpha' })).toBeTruthy()