fix(web): address session fork review findings
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
|
||||
README.md: a1b58f4abe0925be3b426d10344777e46caa9ba0
|
||||
README.zh.md: a472507bc45549c8feb55a75d294cbd7b3138cc5
|
||||
README.md: f0dbd3de4ac5b7934b4077b2986f5def5be329c8
|
||||
README.zh.md: b9f068c6d1709f0a01d22c3fb1cdc391764d666b
|
||||
|
||||
@@ -6,6 +6,8 @@ Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sideba
|
||||
|
||||
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration.
|
||||
|
||||
The Session row's Fork action forks at the source's last completed turn and opens the child; the lineage-aware tree nests it beneath the source. A failure leaves the current selection unchanged.
|
||||
|
||||
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
|
||||
|
||||
## Model Experience
|
||||
@@ -18,5 +20,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No Session deletion or fork control** — the Session menu's Fork and Delete rows remain visual-only (Rename is wired); Workspace registration deletion does not delete Sessions.
|
||||
- **No Session deletion control** — the Session menu's Delete row remains visual-only; Workspace registration deletion does not delete Sessions.
|
||||
- **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow.
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。
|
||||
|
||||
Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork 并打开子会话;感知谱系的树会将子会话嵌套在源会话下。失败不会改变当前选中项。
|
||||
|
||||
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
|
||||
|
||||
## 模型体验
|
||||
@@ -18,5 +20,5 @@
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **没有 Session 删除与 fork 控件**:Session 菜单的 Fork 与 Delete 行仍仅提供视觉效果(Rename 已接线);删除 Workspace 注册记录不会删除 Session。
|
||||
- **没有 Session 删除控件**:Session 菜单的 Delete 行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。
|
||||
- **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。可远程的选取是 `-browse` 组合的应用内流程。
|
||||
|
||||
@@ -481,8 +481,8 @@ export function WorkspaceBrowser({
|
||||
|
||||
{/* Always-mounted seat keeps the region's flex slot while the list
|
||||
itself is wide-only. */}
|
||||
<div className={css.listArea}>
|
||||
{wide && (groupBy === 'flat'
|
||||
<div className={css.listArea}>
|
||||
{wide && (groupBy === 'flat'
|
||||
? <FlatList useSessions={useSessions} open={open} forkSession={forkSession} onSessionRename={onSessionRename} query={query} />
|
||||
: (
|
||||
<SessionTree
|
||||
|
||||
@@ -261,10 +261,10 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
|
||||
open={menuOpen}
|
||||
onClose={() => { setMenuOpen(false) }}
|
||||
items={SESSION_MENU_ITEMS}
|
||||
onSelect={(id) => {
|
||||
setMenuOpen(false)
|
||||
if (id === 'rename') onRename(node.id, row.title)
|
||||
if (id === 'fork') onFork(node.id) // delete stays visual-only.
|
||||
onSelect={(id) => {
|
||||
setMenuOpen(false)
|
||||
if (id === 'rename') onRename(node.id, row.title)
|
||||
if (id === 'fork') onFork(node.id) // delete stays visual-only.
|
||||
}}
|
||||
portal
|
||||
closeOnPointerLeave
|
||||
|
||||
@@ -67,9 +67,9 @@ describe('workspace browser rows', () => {
|
||||
}
|
||||
const onOpen = vi.fn()
|
||||
const onToggle = vi.fn()
|
||||
const view = render(
|
||||
<SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={onToggle} />,
|
||||
const view = render(
|
||||
<SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={onToggle} />,
|
||||
)
|
||||
|
||||
const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')!
|
||||
@@ -88,9 +88,9 @@ describe('workspace browser rows', () => {
|
||||
|
||||
view.rerender(
|
||||
<SessionNodeItem
|
||||
node={{ ...parent, children: [], expanded: false, running: false }}
|
||||
depth={1} currentId={undefined} now={0} onOpen={onOpen}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={onToggle}
|
||||
node={{ ...parent, children: [], expanded: false, running: false }}
|
||||
depth={1} currentId={undefined} now={0} onOpen={onOpen}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={onToggle}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy()
|
||||
@@ -156,15 +156,16 @@ describe('workspace browser rows', () => {
|
||||
expect(screen.queryByRole('button', { name: /Workspace actions/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('session row menu opens without opening the session and dispatches rename', () => {
|
||||
it('session row menu opens without opening the session and dispatches rename and fork', () => {
|
||||
const onOpen = vi.fn()
|
||||
const onRename = vi.fn()
|
||||
const onFork = vi.fn()
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'One', children: [], hasChildren: false,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen}
|
||||
onRename={onRename} onFork={vi.fn()} onToggle={vi.fn()} />)
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen}
|
||||
onRename={onRename} onFork={onFork} onToggle={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/)
|
||||
@@ -173,9 +174,10 @@ describe('workspace browser rows', () => {
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(onRename).toHaveBeenCalledWith(node.id, 'One')
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
// Fork and Delete stay visual-only.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' }))
|
||||
expect(onFork).toHaveBeenCalledWith(node.id)
|
||||
// Delete stays visual-only.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete session' }))
|
||||
expect(onRename).toHaveBeenCalledOnce()
|
||||
@@ -189,9 +191,9 @@ describe('workspace browser rows', () => {
|
||||
const node: SessionNode = {
|
||||
id: sid('p'), title: 'Parent', children: [], hasChildren: true,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} flat />)
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} flat />)
|
||||
expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull()
|
||||
})
|
||||
|
||||
@@ -201,9 +203,9 @@ describe('workspace browser rows', () => {
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'Hovered', children: [], hasChildren: false,
|
||||
expanded: false, running: true, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} />)
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} />)
|
||||
const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
@@ -228,9 +230,9 @@ describe('workspace browser rows', () => {
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'Quiet', children: [], hasChildren: false,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} />)
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} />)
|
||||
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getByText('Idle')).toBeTruthy()
|
||||
@@ -246,9 +248,9 @@ describe('workspace browser rows', () => {
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
}
|
||||
const inactive = dragProps()
|
||||
const { rerender } = render(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
|
||||
const { rerender } = render(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
|
||||
)
|
||||
const row = screen.getByRole('treeitem')
|
||||
stubRect(row)
|
||||
@@ -264,9 +266,9 @@ describe('workspace browser rows', () => {
|
||||
expect(inactive.end).toHaveBeenCalledOnce()
|
||||
|
||||
const active = dragProps({ active: true, marker: 'before' })
|
||||
rerender(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} drag={active} />,
|
||||
rerender(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} drag={active} />,
|
||||
)
|
||||
stubRect(screen.getByRole('treeitem'))
|
||||
// Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
|
||||
@@ -278,9 +280,9 @@ describe('workspace browser rows', () => {
|
||||
expect(active.drop).toHaveBeenCalledWith('after')
|
||||
|
||||
const after = dragProps({ active: true, marker: 'after' })
|
||||
rerender(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} drag={after} />,
|
||||
rerender(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} drag={after} />,
|
||||
)
|
||||
expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user