fix(ui-workspace): show approval-waiting sessions
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: 1497f816a295e2cd156af9b779bce0b42759e1c7
|
||||
README.zh.md: be496412db9790b0625b40f0bbb06c1d406af015
|
||||
|
||||
@@ -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.
|
||||
|
||||
Session rows project the runtime's live `waitingApproval` fact: an amber warning dot takes precedence over the blue running indicator, and the hover card reports **Waiting for approval** until the request is resolved. Running and idle presentation is unchanged when no approval is pending.
|
||||
|
||||
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
|
||||
|
||||
@@ -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 行会投影 runtime 的实时 `waitingApproval` 状态:琥珀色警告点优先于蓝色运行指示器,hover 卡片在请求解决前显示 **Waiting for approval**。没有待审批请求时,运行与空闲展示保持不变。
|
||||
|
||||
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -121,15 +121,23 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
|
||||
* @param props.onToggle - unfold/fold a subtree by id.
|
||||
* @returns the node's row followed by its children.
|
||||
*/
|
||||
/** Hover-card body: full title, relative time, and the status line (running/idle until wire status lands). */
|
||||
/** Session status presentation; approval waiting outranks the underlying running state. */
|
||||
function sessionStatus(node: SessionNode): { state: 'warning' | 'ongoing' | 'done'; label: string } {
|
||||
if (node.waitingApproval) return { state: 'warning', label: 'Waiting for approval' }
|
||||
if (node.running) return { state: 'ongoing', label: 'Running' }
|
||||
return { state: 'done', label: 'Idle' }
|
||||
}
|
||||
|
||||
/** Hover-card body: full title, relative time, and approval/running/idle status. */
|
||||
function SessionHoverContent({ node, now }: { node: SessionNode; now: number }) {
|
||||
const status = sessionStatus(node)
|
||||
return (
|
||||
<div className={css.hoverContent}>
|
||||
<div className={css.hoverTitle}>{node.title}</div>
|
||||
<div className={css.hoverTime}>{`${formatRelativeTime(node.updatedAt, now)} ago`}</div>
|
||||
<div className={css.hoverStatus}>
|
||||
<StateDot state={node.running ? 'ongoing' : 'done'} />
|
||||
<span>{node.running ? 'Running' : 'Idle'}</span>
|
||||
<StateDot state={status.state} />
|
||||
<span>{status.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -175,6 +183,7 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
|
||||
}) {
|
||||
const row = node
|
||||
const selected = node.id === currentId
|
||||
const status = sessionStatus(node)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
// Rail (figma session cell: pad 8, twist slot 16, status slot 16, gap 4 to
|
||||
// the title): both slots are always reserved so titles align whether or not
|
||||
@@ -226,7 +235,7 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
|
||||
<span className={css.slot}>{(row.waitingApproval || row.running) && <StateDot state={status.state} />}</span>
|
||||
<span className={css.title}>{row.title}</span>
|
||||
<span className={css.time}>{formatRelativeTime(row.updatedAt, now)}</span>
|
||||
<span className={css.rowActions}>
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface SessionNode {
|
||||
/** The session HAS children in the data (the twist renders even while folded). */
|
||||
hasChildren: boolean
|
||||
expanded: boolean
|
||||
/** A pending approval takes display precedence over the running state. */
|
||||
waitingApproval: boolean
|
||||
running: boolean
|
||||
updatedAt: number
|
||||
}
|
||||
@@ -183,6 +185,7 @@ function sessionNode(s: SessionSummary, children: readonly SessionNode[], hasChi
|
||||
children,
|
||||
hasChildren,
|
||||
expanded,
|
||||
waitingApproval: s.waitingApproval,
|
||||
running: s.running,
|
||||
updatedAt: s.updatedAt,
|
||||
}
|
||||
|
||||
@@ -59,11 +59,11 @@ describe('workspace browser rows', () => {
|
||||
it('renders and operates selected, running, recursive Session nodes', () => {
|
||||
const child: SessionNode = {
|
||||
id: sid('child'), title: 'Child', children: [], hasChildren: false,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
expanded: false, waitingApproval: false, running: false, updatedAt: 0,
|
||||
}
|
||||
const parent: SessionNode = {
|
||||
id: sid('parent'), title: 'Parent', children: [child], hasChildren: true,
|
||||
expanded: true, running: true, updatedAt: 0,
|
||||
expanded: true, waitingApproval: false, running: true, updatedAt: 0,
|
||||
}
|
||||
const onOpen = vi.fn()
|
||||
const onToggle = vi.fn()
|
||||
@@ -142,7 +142,7 @@ describe('workspace browser rows', () => {
|
||||
const onRename = vi.fn()
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'One', children: [], hasChildren: false,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
expanded: false, waitingApproval: false, running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen}
|
||||
onRename={onRename} onToggle={vi.fn()} />)
|
||||
@@ -169,7 +169,7 @@ describe('workspace browser rows', () => {
|
||||
it('flat variant renders no twist even for a parent and ignores toggling', () => {
|
||||
const node: SessionNode = {
|
||||
id: sid('p'), title: 'Parent', children: [], hasChildren: true,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
expanded: false, waitingApproval: false, running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} flat />)
|
||||
@@ -181,7 +181,7 @@ describe('workspace browser rows', () => {
|
||||
try {
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'Hovered', children: [], hasChildren: false,
|
||||
expanded: false, running: true, updatedAt: 0,
|
||||
expanded: false, waitingApproval: false, running: true, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} />)
|
||||
@@ -203,12 +203,34 @@ describe('workspace browser rows', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('shows approval waiting as warning ahead of the running state', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const node: SessionNode = {
|
||||
id: sid('approval'), title: 'Needs approval', children: [], hasChildren: false,
|
||||
expanded: false, waitingApproval: true, running: true, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} />)
|
||||
const row = screen.getByRole('treeitem')
|
||||
expect(row.querySelector('[data-state="warning"]')).toBeTruthy()
|
||||
expect(row.querySelector('[data-state="ongoing"]')).toBeNull()
|
||||
|
||||
fireEvent.pointerEnter(row.parentElement as HTMLElement)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getByText('Waiting for approval')).toBeTruthy()
|
||||
expect(document.querySelectorAll('[data-state="warning"]')).toHaveLength(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('idle hover card shows the Idle status line', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'Quiet', children: [], hasChildren: false,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
expanded: false, waitingApproval: false, running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} />)
|
||||
@@ -224,7 +246,7 @@ describe('workspace browser rows', () => {
|
||||
it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => {
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'Drag me', children: [], hasChildren: false,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
expanded: false, waitingApproval: false, running: false, updatedAt: 0,
|
||||
}
|
||||
const inactive = dragProps()
|
||||
const { rerender } = render(
|
||||
|
||||
@@ -33,6 +33,14 @@ describe('deriveGroups', () => {
|
||||
expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')])
|
||||
})
|
||||
|
||||
it('projects approval-waiting state into grouped and flat rows', () => {
|
||||
const awaiting = { ...summary('awaiting', 10), waitingApproval: true, running: true }
|
||||
const sessions = list(awaiting)
|
||||
const grouped = deriveGroups(sessions, [workspace('project', ['awaiting'])], view(['project']))
|
||||
expect(grouped[0]!.sessions[0]).toMatchObject({ waitingApproval: true, running: true })
|
||||
expect(deriveFlat(sessions, { query: '' })[0]).toMatchObject({ waitingApproval: true, running: true })
|
||||
})
|
||||
|
||||
it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => {
|
||||
const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other'))
|
||||
const groups = deriveGroups(sessions, [workspace('first', ['owned'])], view([UNGROUPED_KEY]))
|
||||
|
||||
Reference in New Issue
Block a user