fix(client): address sidebar activity review

This commit is contained in:
Yichen Jiang
2026-08-08 15:14:29 +08:00
parent e9103ad5ee
commit b3a53c1e51
10 changed files with 56 additions and 19 deletions

View File

@@ -433,7 +433,7 @@ export function SubagentCatalogAction({
// the already-visible direct rows during that short bootstrap window.
const descendantCount = Math.max(healthy.length, descendants.count)
const totalCountKey = descendantCount === 1 ? 'count.total.one' : 'count.total.other'
const runningCountKey = descendantCount === 1 ? 'count.running.one' : 'count.running.other'
const runningCountKey = descendants.runningCount === 1 ? 'count.running.one' : 'count.running.other'
// Session summaries can announce membership before the descriptor-backed catalog catches up.
// Keep that entry point visible through disabled loading rows; only catalog rows are navigable.
const summaryBackedLoading = descendants.count > 0
@@ -564,7 +564,10 @@ export function SubagentCatalogAction({
className={css.trigger}
aria-haspopup="tree"
aria-expanded={open}
aria-label={t(descendants.runningCount > 0 ? runningCountKey : totalCountKey, { count: descendantCount })}
aria-label={t(
descendants.runningCount > 0 ? runningCountKey : totalCountKey,
{ count: descendants.runningCount > 0 ? descendants.runningCount : descendantCount },
)}
onClick={() => { changeOpen(!open) }}
onKeyDown={(event) => {
if (event.key !== 'ArrowDown') return

View File

@@ -108,7 +108,7 @@ describe('SubagentCatalogAction', () => {
}
const view = render(<SubagentCatalogAction {...props(catalog(), {}, summaries)} />)
const trigger = screen.getByRole('button', { name: '3 个子代理,正在运行' })
const trigger = screen.getByRole('button', { name: '1 个子代理,正在运行' })
expect(trigger.querySelector('[data-state="ongoing"]')).not.toBeNull()
view.rerender(<SubagentCatalogAction {...props(catalog(), {}, {
@@ -322,7 +322,9 @@ describe('SubagentCatalogAction', () => {
})) as Record<SessionId, SessionSummary>
const input = props(catalog({ entries }), {}, summaries)
render(<SubagentCatalogAction {...input} />)
fireEvent.click(screen.getByRole('button', { name: /9 个子代理/ }))
const trigger = screen.getByRole('button', { name: '1 个子代理,正在运行' })
expect(within(trigger).getByText('9 个子代理')).toBeTruthy()
fireEvent.click(trigger)
const runningRow = screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分10秒/ })
const runningMetrics = within(runningRow)
@@ -506,7 +508,8 @@ describe('SubagentCatalogAction', () => {
const absent = props(undefined, {}, summaries)
const view = render(<SubagentCatalogAction {...absent} />)
const trigger = screen.getByRole('button', { name: '2 个子代理,正在运行' })
const trigger = screen.getByRole('button', { name: '1 个子代理,正在运行' })
expect(within(trigger).getByText('2 个子代理')).toBeTruthy()
fireEvent.click(trigger)
expect(absent.setCatalogOpen).toHaveBeenCalledWith(PARENT, true)
expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2)
@@ -514,7 +517,7 @@ describe('SubagentCatalogAction', () => {
const staleEmpty = props(catalog({ entries: [] }), {}, summaries)
view.rerender(<SubagentCatalogAction {...staleEmpty} />)
expect(screen.getByRole('button', { name: '2 个子代理,正在运行' })).toBeTruthy()
expect(screen.getByRole('button', { name: '1 个子代理,正在运行' })).toBeTruthy()
expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2)
expect(staleEmpty.openChild).not.toHaveBeenCalled()
})

View File

@@ -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: b2baea049c30f46c3194009e71c70b38973dc526
README.zh.md: cc50ba3691db10a533337b0e99689fba72a679ca
README.md: 1ec07bd41e72bb5a26b2cfc3bf90e57e7d92db08
README.zh.md: 18d9e356a9e5deb38624f377d41b2fc8bc677294

View File

@@ -16,7 +16,7 @@ Session rows render the runtime's live `pendingInteraction` classification: appr
Both target slots are declared by other plugins, so `apply` uses `slots.inject()` to register for each declaration lifetime and re-register after a declaring slot is restored.
The shared sidebar projection hides rows whose durable Session summary has `origin: 'subagent'`; users enter those conversations through the selected parent's subagent header catalog. Each visible ordinary row inherits the blue activity indicator while any descendant reached through uninterrupted subagent-origin lineage is running, and its hover and assistive text report the exact running-descendant count without describing an idle parent as running. Ordinary forks remain visible and terminate this aggregation because lineage alone does not set their origin. Pending user interaction remains the primary row marker while descendant activity stays available as a separate hover and assistive status. The runtime keeps hidden rows available for conversation, title, and addressed transport state.
The shared sidebar projection hides rows whose durable Session summary has `origin: 'subagent'`; users enter those conversations through the selected parent's subagent header catalog. Each visible ordinary row inherits the blue activity indicator while any descendant reached through uninterrupted subagent-origin lineage is running, and its hover and assistive text report the exact running-descendant count without describing an idle parent as running. Ordinary forks remain visible and terminate this aggregation because lineage alone does not set their origin. Pending user interaction outranks the session's own running state, and either remains the primary row status while descendant activity stays available as a separate hover and assistive status. With neither present, descendant activity outranks the green unviewed-completion reminder; the reminder returns once no descendant is running. The runtime keeps hidden rows available for conversation, title, and addressed transport state.
## Model Experience

View File

@@ -16,7 +16,7 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**
两个目标 slot 都由其他插件声明,因此 `apply` 使用 `slots.inject()` 在各自的声明生命周期内完成注册,并在目标 slot 的声明恢复后重新注册。
共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;用户从所选 parent 的 subagent 页头目录进入这些对话。每个可见的普通行都会在经不间断的 subagent 谱系可达的任一后代运行时继承蓝色活动指示器;其悬停与无障碍文本会报告确切的运行中后代数量,同时不会把空闲 parent 描述为正在运行。普通 fork 仍然可见,并会终止此聚合,因为仅有谱系不会设置该 origin。待处理的用户交互仍是主要行标记,而后代活动作为独立的悬停与无障碍状态保留。运行时仍保留隐藏行,供对话、标题与已寻址传输状态使用。
共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;用户从所选 parent 的 subagent 页头目录进入这些对话。每个可见的普通行都会在经不间断的 subagent 谱系可达的任一后代运行时继承蓝色活动指示器;其悬停与无障碍文本会报告确切的运行中后代数量,同时不会把空闲 parent 描述为正在运行。普通 fork 仍然可见,并会终止此聚合,因为仅有谱系不会设置该 origin。待处理的用户交互优先于会话自身的运行中状态,二者无论哪一项存在都会保持为行的主要状态,而后代活动作为独立的悬停与无障碍状态保留。两者均不存在时,后代活动优先于绿色的未查看完成提醒;最后一个运行中的后代停止后,该提醒会重新出现。运行时仍保留隐藏行,供对话、标题与已寻址传输状态使用。
## 模型体验

View File

@@ -176,7 +176,10 @@ interface SessionStatus {
label: string
}
/** Session status presentation; pending user interaction remains primary. */
/**
* Session status presentation; pending interaction is primary and live activity
* outranks completion reminders.
*/
function sessionStatuses(
node: Pick<SessionNode, 'pendingInteraction' | 'running' | 'runningSubagentCount' | 'completed'>,
t: RowTranslate,
@@ -386,9 +389,9 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
drag.drop(rowHalf(e))
}}
>
{/* Pending interactions and running outrank the idle state; a
finished-but-unviewed session shows the green done reminder dot
(cleared by opening the session). */}
{/* Pending interaction and own or descendant activity outrank the
finished-but-unviewed reminder, which returns after activity stops
and is cleared by opening the session. */}
<span className={css.slot}>
{(primaryStatus.state !== 'done' || row.completed) && (
<>

View File

@@ -133,7 +133,7 @@ describe('workspace browser rows', () => {
expect(onOpen).toHaveBeenCalledWith(node.id)
})
it('shows the green done dot only on a finished, unviewed session (running wins the slot)', () => {
it('shows the green done dot only on a finished, unviewed session (live activity wins the slot)', () => {
const renderRow = (over: Partial<SessionNode>) => render(
<SessionNodeItem
node={{
@@ -158,6 +158,11 @@ describe('workspace browser rows', () => {
const running = renderRow({ completed: true, running: true })
expect(running.container.querySelector('[data-state="ongoing"]')).not.toBeNull()
expect(running.container.querySelector('[data-state="done"]')).toBeNull()
running.unmount()
// Descendant activity also wins until the last running descendant stops.
const delegated = renderRow({ completed: true, runningSubagentCount: 1 })
expect(delegated.container.querySelector('[data-state="ongoing"]')).not.toBeNull()
expect(delegated.container.querySelector('[data-state="done"]')).toBeNull()
})
it('shows descendant activity without describing an idle parent as running', () => {
@@ -182,6 +187,29 @@ describe('workspace browser rows', () => {
}
})
it('keeps descendant activity secondary while the parent is running', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('owner'), title: 'Delegating', blank: false, running: true,
runningSubagentCount: 1, completed: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
const row = screen.getByRole('treeitem')
expect(row.querySelectorAll('[data-state="ongoing"]')).toHaveLength(1)
expect(screen.getByText('进行中')).toBeTruthy()
expect(screen.getByText('1 个子代理运行中')).toBeTruthy()
fireEvent.pointerEnter(row.parentElement as HTMLElement)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getAllByText('进行中')).toHaveLength(2)
expect(screen.getAllByText('1 个子代理运行中')).toHaveLength(2)
} finally {
vi.useRealTimers()
}
})
it('keeps child activity as a secondary status while user attention is primary', () => {
const node: SessionNode = {
id: sid('owner'), title: 'Needs input', blank: false, pendingInteraction: 'question',