feat(client): surface subagent activity in sidebar

This commit is contained in:
Yichen Jiang
2026-08-08 14:42:07 +08:00
parent 915a56208d
commit 04e6a98064
20 changed files with 320 additions and 93 deletions

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/runtime/README.md
README.md: 8ac29a4258bbd7456b20c61e547d48c570e84d27
README.zh.md: 0e065e43ecc571e68d3976d2100eb43959cb2e3d
README.md: 00427f33b1dfc23b157c8fe4cfefb42cf313ee66
README.zh.md: 0a27602a4408792e7f02ecd3995aeb5946e81aab

View File

@@ -22,6 +22,8 @@ Workspace and Session lists have independent monotone `pending` → `ready` base
SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store.
`indexSubagentDescendants()` derives per-parent total and running descendant counts from the retained list mirror. It follows only uninterrupted `origin: 'subagent'` ancestry, so an ordinary fork starts a separate ownership subtree; cycles stop without throwing, and a missing parent remains a harmless key until its summary arrives.
`SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it.
## New Session and the blank mirror

View File

@@ -22,6 +22,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线
SlotsService 分别为 renderer 提供 `useSessions``useWorkspaces` 的裸 observableweb-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
`indexSubagentDescendants()` 从保留的列表镜像中派生每个 parent 的后代总数与运行中后代数。它只沿不间断的 `origin: 'subagent'` 祖先链追踪,因此普通 fork 会开启独立的归属子树;遇到环时,追踪会停止但不会抛出异常,缺失的 parent 则会保留为无害的键,直至其摘要到达。
`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit``SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。
## New Session 与 blank 镜像

View File

@@ -15,6 +15,8 @@ export { SlotsService } from './slots.ts'
export type { RootOwnerProps } from './slots.ts'
export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
export { SessionHistoryService } from './session-history/service.ts'
export { indexSubagentDescendants } from './sessions/subagent-lineage.ts'
export type { SubagentDescendantSummary } from './sessions/subagent-lineage.ts'
// The provide channel is shared with the client test runtime (one
// materialization/projection implementation; no test-side mirror to drift).
export { SessionProvideChannel } from './sessions/provide.ts'

View File

@@ -0,0 +1,50 @@
/**
* Pure subagent-lineage aggregation over the retained session-list mirror.
* Ordinary forks terminate propagation so each visible session owns only its
* uninterrupted subagent subtree.
* @module @deepseek-ai/dsh-client-runtime/client/sessions/subagent-lineage
*/
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionSummary } from './service.ts'
/** Descendant counts projected for one possible parent session. */
export interface SubagentDescendantSummary {
/** All descendants connected through uninterrupted subagent-origin lineage. */
readonly count: number
/** Descendants whose exact session summary is currently running. */
readonly runningCount: number
}
/**
* Index every subagent descendant under each ancestor it reaches through an
* uninterrupted subagent-origin chain. Cycles fail soft and orphan owners
* remain harmless map keys until their summaries arrive.
* @param summaries - retained session summaries keyed by id.
* @returns descendant totals and running totals keyed by possible parent id.
*/
export function indexSubagentDescendants(
summaries: Readonly<Record<SessionId, SessionSummary>>,
): ReadonlyMap<SessionId, SubagentDescendantSummary> {
const indexed = new Map<SessionId, { count: number; runningCount: number }>()
for (const descendant of Object.values(summaries)) {
if (descendant.origin !== 'subagent') continue
const seen = new Set<SessionId>()
let current: SessionSummary | undefined = descendant
while (current?.origin === 'subagent' && current.parentId !== undefined
&& !seen.has(current.id)) {
seen.add(current.id)
const aggregate = indexed.get(current.parentId)
if (aggregate === undefined) {
indexed.set(current.parentId, {
count: 1,
runningCount: descendant.running ? 1 : 0,
})
} else {
aggregate.count += 1
if (descendant.running) aggregate.runningCount += 1
}
current = summaries[current.parentId]
}
}
return indexed
}

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import { indexSubagentDescendants } from '@deepseek-ai/dsh-client-runtime/client'
const sid = (id: string) => id as SessionId
function summary(
id: string,
parentId?: SessionId,
origin?: 'subagent',
running = false,
): SessionSummary {
return {
id: sid(id), displayTitle: id, running, blank: false, updatedAt: 0,
...(parentId === undefined ? {} : { parentId }),
...(origin === undefined ? {} : { origin }),
}
}
function index(...summaries: SessionSummary[]) {
return indexSubagentDescendants(Object.fromEntries(
summaries.map(item => [item.id, item]),
))
}
describe('indexSubagentDescendants', () => {
it('counts every nested descendant and its exact running state', () => {
const owner = summary('owner')
const child = summary('child', owner.id, 'subagent')
const grandchild = summary('grandchild', child.id, 'subagent', true)
const result = index(owner, child, grandchild)
expect(result.get(owner.id)).toEqual({ count: 2, runningCount: 1 })
expect(result.get(child.id)).toEqual({ count: 1, runningCount: 1 })
})
it('stops at ordinary forks and fails soft on cycles and missing parents', () => {
const owner = summary('owner')
const child = summary('child', owner.id, 'subagent', true)
const fork = summary('fork', child.id)
const forkChild = summary('fork-child', fork.id, 'subagent', true)
const orphan = summary('orphan', sid('missing'), 'subagent', true)
const cycleA = summary('cycle-a', sid('cycle-b'), 'subagent')
const cycleB = summary('cycle-b', sid('cycle-a'), 'subagent')
const result = index(owner, child, fork, forkChild, orphan, cycleA, cycleB)
expect(result.get(owner.id)).toEqual({ count: 1, runningCount: 1 })
expect(result.get(fork.id)).toEqual({ count: 1, runningCount: 1 })
expect(result.get(sid('missing'))).toEqual({ count: 1, runningCount: 1 })
expect(result.get(cycleA.id)).toEqual({ count: 2, runningCount: 0 })
expect(result.get(cycleB.id)).toEqual({ count: 2, runningCount: 0 })
})
})

View File

@@ -1,9 +1,9 @@
import {
useEffect, useRef, useState, type KeyboardEvent, type MouseEvent,
useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent,
} from 'react'
import type {
SessionId, SessionListState, SessionProjectionMap, SessionSummary, SubagentAddress,
SubagentCatalogSnapshot,
import {
indexSubagentDescendants, type SessionId, type SessionListState, type SessionProjectionMap,
type SessionSummary, type SubagentAddress, type SubagentCatalogSnapshot,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot,
@@ -171,30 +171,7 @@ function formatExactDuration(ms: number, t: TranslateNS<typeof NS>): string {
})
}
/** Aggregate the complete subagent-only descendant subtree from flat summaries. */
function summarizeDescendants(
sessionId: SessionId,
summaries: Readonly<Record<SessionId, SessionSummary>>,
): { count: number; running: boolean } {
let count = 0
let running = false
for (const summary of Object.values(summaries)) {
if (summary.origin !== 'subagent') continue
const seen = new Set<SessionId>()
let current: SessionSummary | undefined = summary
while (current?.origin === 'subagent' && current.parentId !== undefined
&& !seen.has(current.id)) {
seen.add(current.id)
if (current.parentId === sessionId) {
count += 1
running ||= summary.running
break
}
current = summaries[current.parentId]
}
}
return { count, running }
}
const NO_DESCENDANTS = { count: 0, runningCount: 0 } as const
/** Render the known direct-child shape while its authoritative catalog hydrates. */
function CatalogLoadingRows({
@@ -448,7 +425,10 @@ export function SubagentCatalogAction({
const setCatalogOpenRef = useRef(setCatalogOpen)
setCatalogOpenRef.current = setCatalogOpen
const healthy = catalog?.entries.filter(entry => entry.kind === 'child') ?? []
const descendants = summarizeDescendants(sessionId, summaries)
const descendants = useMemo(
() => indexSubagentDescendants(summaries).get(sessionId) ?? NO_DESCENDANTS,
[sessionId, summaries],
)
// The catalog can arrive before the session-list baseline; never undercount
// the already-visible direct rows during that short bootstrap window.
const descendantCount = Math.max(healthy.length, descendants.count)
@@ -527,10 +507,10 @@ export function SubagentCatalogAction({
}, [open])
useEffect(() => {
if (!open || !descendants.running) return
if (!open || descendants.runningCount === 0) return
const timer = setInterval(() => { setNow(Date.now()) }, 1_000)
return () => { clearInterval(timer) }
}, [open, descendants.running])
}, [open, descendants.runningCount])
useEffect(() => () => {
for (const parentSessionId of observedCatalogs.current) {
@@ -584,7 +564,7 @@ export function SubagentCatalogAction({
className={css.trigger}
aria-haspopup="tree"
aria-expanded={open}
aria-label={t(descendants.running ? runningCountKey : totalCountKey, { count: descendantCount })}
aria-label={t(descendants.runningCount > 0 ? runningCountKey : totalCountKey, { count: descendantCount })}
onClick={() => { changeOpen(!open) }}
onKeyDown={(event) => {
if (event.key !== 'ArrowDown') return
@@ -594,7 +574,7 @@ export function SubagentCatalogAction({
}}
>
<span className={css.activitySlot}>
{descendants.running && <StateDot state="ongoing" />}
{descendants.runningCount > 0 && <StateDot state="ongoing" />}
</span>
<span className={css.count}>{t(totalCountKey, { count: descendantCount })}</span>
<IconChevronDownOutline14 className={open ? css.triggerOpen : undefined} />

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: bd7313b560e76378e4fff274c99bb976819aebae
README.zh.md: 734a897b9cb9c3469d8f402b13bff4b62753f9b2
README.md: b2baea049c30f46c3194009e71c70b38973dc526
README.zh.md: cc50ba3691db10a533337b0e99689fba72a679ca

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. Ordinary forks remain visible because lineage alone does not set that origin. 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 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.
## Model Experience

View File

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

View File

@@ -45,6 +45,8 @@ export const zh = {
'actions.session.aria': '会话“{name}”的操作',
'actions.newSession.aria': '在“{name}”中新建会话',
'status.running': '进行中',
'status.subagentsRunning.one': '{n} 个子代理运行中',
'status.subagentsRunning.other': '{n} 个子代理运行中',
'status.idle': '空闲',
'status.waitingApproval': '等待审批',
'status.planReview': '计划待审',
@@ -106,6 +108,8 @@ export const en = {
'actions.session.aria': 'Session actions for {name}',
'actions.newSession.aria': 'New session in {name}',
'status.running': 'Running',
'status.subagentsRunning.one': '{n} subagent running',
'status.subagentsRunning.other': '{n} subagents running',
'status.idle': 'Idle',
'status.waitingApproval': 'Waiting for approval',
'status.planReview': 'Plan awaiting review',

View File

@@ -171,37 +171,67 @@ function assertNever(value: never): never {
throw new Error(`unknown pending interaction: ${String(value)}`)
}
/** Session status presentation; pending user interaction outranks the running state. */
function sessionStatus(
node: Pick<SessionNode, 'pendingInteraction' | 'running' | 'completed'>,
interface SessionStatus {
state: StateDotState
label: string
}
/** Session status presentation; pending user interaction remains primary. */
function sessionStatuses(
node: Pick<SessionNode, 'pendingInteraction' | 'running' | 'runningSubagentCount' | 'completed'>,
t: RowTranslate,
): { state: StateDotState; label: string } {
): readonly [SessionStatus, ...SessionStatus[]] {
const subagents: SessionStatus | undefined = node.runningSubagentCount === 0
? undefined
: {
state: 'ongoing',
label: t(
node.runningSubagentCount === 1
? 'status.subagentsRunning.one'
: 'status.subagentsRunning.other',
{ n: node.runningSubagentCount },
),
}
let pending: SessionStatus | undefined
switch (node.pendingInteraction) {
case 'approval': return { state: 'warning', label: t('status.waitingApproval') }
case 'plan-review': return { state: 'warning', label: t('status.planReview') }
case 'question': return { state: 'warning', label: t('status.waitingAnswer') }
case 'approval':
pending = { state: 'warning', label: t('status.waitingApproval') }
break
case 'plan-review':
pending = { state: 'warning', label: t('status.planReview') }
break
case 'question':
pending = { state: 'warning', label: t('status.waitingAnswer') }
break
case undefined: break
/* v8 ignore next -- closed PendingInteractionStatus union */
default: return assertNever(node.pendingInteraction)
}
if (node.running) return { state: 'ongoing', label: t('status.running') }
if (node.completed) return { state: 'done', label: t('status.completed') }
return { state: 'done', label: t('status.idle') }
if (pending !== undefined) return subagents === undefined ? [pending] : [pending, subagents]
if (node.running) {
const primary: SessionStatus = { state: 'ongoing', label: t('status.running') }
return subagents === undefined ? [primary] : [primary, subagents]
}
if (subagents !== undefined) return [subagents]
if (node.completed) return [{ state: 'done', label: t('status.completed') }]
return [{ state: 'done', label: t('status.idle') }]
}
/** Hover-card body: full title, relative time, and interaction/running/completed/idle status. */
/** Hover-card body: full title, relative time, and every relevant live status. */
function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) {
const status = sessionStatus(node, t)
const statuses = sessionStatuses(node, t)
return (
<div className={css.hoverContent}>
<div className={css.hoverTitle}>{displayTitle(node, t)}</div>
{/* Same placeholder rule as the row's trailing cell: no timestamp
before the first prompt. */}
{!node.blank && <div className={css.hoverTime}>{hoverTimeLabel(node.updatedAt, now, t)}</div>}
<div className={css.hoverStatus}>
<StateDot state={status.state} />
<span>{status.label}</span>
</div>
{statuses.map(status => (
<div className={css.hoverStatus} key={status.label}>
<StateDot state={status.state} />
<span>{status.label}</span>
</div>
))}
</div>
)
}
@@ -241,7 +271,8 @@ export function SearchResultItem({ result, currentId, onOpen, t }: {
t: RowTranslate
}) {
const selected = result.id === currentId
const status = sessionStatus(result, t)
const statuses = sessionStatuses(result, t)
const primaryStatus = statuses[0]
return (
<button
type="button"
@@ -252,10 +283,12 @@ export function SearchResultItem({ result, currentId, onOpen, t }: {
>
<span className={css.searchResultHeading}>
<span className={css.slot}>
{(status.state !== 'done' || result.completed) && (
{(primaryStatus.state !== 'done' || result.completed) && (
<>
<StateDot state={status.state} />
<span className={css.visuallyHidden}>{status.label}</span>
<StateDot state={primaryStatus.state} />
{statuses.map(status => (
<span className={css.visuallyHidden} key={status.label}>{status.label}</span>
))}
</>
)}
</span>
@@ -277,7 +310,7 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
/**
* One top-level 34px session row: status dot (pending user interaction outranks
* running), title, relative time, and the row actions menu.
* own or descendant activity), title, relative time, and the row actions menu.
* @param props.node - derived session node.
* @param props.currentId - selected session id (row highlight).
* @param props.now - epoch ms for relative-time formatting.
@@ -307,7 +340,8 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
const row = node
const title = displayTitle(node, t)
const selected = node.id === currentId
const status = sessionStatus(node, t)
const statuses = sessionStatuses(node, t)
const primaryStatus = statuses[0]
const [menuOpen, setMenuOpen] = useState(false)
// Archive replaces the former Delete placeholder: it hides the row through
// the registry-global archive set and never touches the session log, so it
@@ -356,10 +390,12 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
finished-but-unviewed session shows the green done reminder dot
(cleared by opening the session). */}
<span className={css.slot}>
{(status.state !== 'done' || row.completed) && (
{(primaryStatus.state !== 'done' || row.completed) && (
<>
<StateDot state={status.state} />
<span className={css.visuallyHidden}>{status.label}</span>
<StateDot state={primaryStatus.state} />
{statuses.map(status => (
<span className={css.visuallyHidden} key={status.label}>{status.label}</span>
))}
</>
)}
</span>

View File

@@ -3,9 +3,10 @@
* Unassigned Sessions trail under Ungrouped; only the selected blank Session
* remains visible.
*/
import type {
PendingInteractionStatus, SessionId, SessionListState, SessionSearchResultItem, SessionSummary,
WorkspaceId, WorkspaceView,
import {
indexSubagentDescendants, type PendingInteractionStatus, type SessionId, type SessionListState,
type SessionSearchResultItem, type SessionSummary, type SubagentDescendantSummary,
type WorkspaceId, type WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
/** Group key for Sessions outside every Workspace. */
@@ -24,6 +25,8 @@ export interface SessionNode {
/** The runtime Session list reports an interaction awaiting this user. */
pendingInteraction?: PendingInteractionStatus
running: boolean
/** Running descendants connected through uninterrupted subagent-origin lineage. */
runningSubagentCount: number
/** Finished running while not selected and not yet opened (the green "done" reminder dot). */
completed: boolean
updatedAt: number
@@ -56,6 +59,8 @@ export interface SearchResultNode {
/** The runtime Session list reports an interaction awaiting this user. */
pendingInteraction?: PendingInteractionStatus
running: boolean
/** Running descendants connected through uninterrupted subagent-origin lineage. */
runningSubagentCount: number
/** Finished running while not selected and not yet opened (the green "done" reminder dot). */
completed: boolean
snippet?: string
@@ -173,12 +178,16 @@ function groupByWorkspace(
return groups
}
function sessionNode(s: SessionSummary): SessionNode {
function sessionNode(
s: SessionSummary,
descendants: ReadonlyMap<SessionId, SubagentDescendantSummary>,
): SessionNode {
return {
id: s.id,
title: sessionTitle(s),
blank: s.blank,
running: s.running,
runningSubagentCount: descendants.get(s.id)?.runningCount ?? 0,
completed: s.completed === true,
updatedAt: s.updatedAt,
...(s.pendingInteraction === undefined ? {} : { pendingInteraction: s.pendingInteraction }),
@@ -207,6 +216,7 @@ export function deriveGroups(
): GroupNode[] {
const archived = new Set(archivedSessionIds)
const expandedProjects = new Set(view.expandedProjects)
const descendants = indexSubagentDescendants(list.byId)
const currentGroup = list.current === undefined
? undefined
: (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined)
@@ -223,7 +233,7 @@ export function deriveGroups(
sessionCount: g.sessions.length,
expanded,
containsCurrent: g.key === currentGroup,
sessions: expanded ? g.sessions.map(sessionNode) : [],
sessions: expanded ? g.sessions.map(session => sessionNode(session, descendants)) : [],
})
}
return groups
@@ -240,6 +250,7 @@ export function deriveGroups(
*/
export function deriveFlat(list: SessionListState, archivedSessionIds: readonly SessionId[]): SessionNode[] {
const archived = new Set(archivedSessionIds)
const descendants = indexSubagentDescendants(list.byId)
const rows: SessionSummary[] = []
for (const id of list.ids) {
const s = list.byId[id]
@@ -247,7 +258,7 @@ export function deriveFlat(list: SessionListState, archivedSessionIds: readonly
rows.push(s)
}
rows.sort(byRecency)
return rows.map(sessionNode)
return rows.map(session => sessionNode(session, descendants))
}
/** Relative-time bucket of a session row's trailing label. */
@@ -282,6 +293,7 @@ export function deriveSearchResults(
const q = query.trim().toLowerCase()
if (q === '') return { items: [], hasMore: false }
const archived = new Set(archivedSessionIds)
const descendants = indexSubagentDescendants(list.byId)
const workspaceBySession = new Map<SessionId, string>()
for (const workspace of workspaces) {
@@ -332,6 +344,7 @@ export function deriveSearchResults(
title: sessionTitle(summary),
workspace: labelOf(summary),
running: summary.running,
runningSubagentCount: descendants.get(summary.id)?.runningCount ?? 0,
...(summary.pendingInteraction === undefined
? {}
: { pendingInteraction: summary.pendingInteraction }),

View File

@@ -64,6 +64,7 @@ describe('workspace browser rows', () => {
title: 'Result title',
workspace: 'Workspace context',
running: true,
runningSubagentCount: 0,
completed: false,
snippet: 'matching message excerpt',
}
@@ -86,7 +87,7 @@ describe('workspace browser rows', () => {
] as const)('shows %s ahead of running in search results', (pendingInteraction, label) => {
const result: SearchResultNode = {
id: sid(pendingInteraction), title: 'Needs input', workspace: 'Project',
pendingInteraction, running: true, completed: false,
pendingInteraction, running: true, runningSubagentCount: 0, completed: false,
}
render(<SearchResultItem result={result} currentId={undefined} onOpen={vi.fn()} t={t} />)
const row = screen.getByRole('treeitem')
@@ -115,7 +116,8 @@ describe('workspace browser rows', () => {
it('renders and opens a selected running Session row', () => {
const node: SessionNode = {
id: sid('session'), title: 'Session', blank: false, running: true, completed: false, updatedAt: 0,
id: sid('session'), title: 'Session', blank: false, running: true,
runningSubagentCount: 0, completed: false, updatedAt: 0,
}
const onOpen = vi.fn()
render(
@@ -134,7 +136,10 @@ describe('workspace browser rows', () => {
it('shows the green done dot only on a finished, unviewed session (running wins the slot)', () => {
const renderRow = (over: Partial<SessionNode>) => render(
<SessionNodeItem
node={{ id: sid('s1'), title: 'One', blank: false, running: false, completed: false, updatedAt: 0, ...over }}
node={{
id: sid('s1'), title: 'One', blank: false, running: false,
runningSubagentCount: 0, completed: false, updatedAt: 0, ...over,
}}
currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t}
/>,
@@ -155,9 +160,48 @@ describe('workspace browser rows', () => {
expect(running.container.querySelector('[data-state="done"]')).toBeNull()
})
it('shows descendant activity without describing an idle parent as running', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('owner'), title: 'Delegating', blank: false, running: false,
runningSubagentCount: 2, 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.querySelector('[data-state="ongoing"]')).not.toBeNull()
expect(screen.getByText('2 个子代理运行中')).toBeTruthy()
expect(screen.queryByText('进行中')).toBeNull()
fireEvent.pointerEnter(row.parentElement as HTMLElement)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getAllByText('2 个子代理运行中')).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',
running: false, 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.querySelector('[data-state="warning"]')).not.toBeNull()
expect(row.querySelector('[data-state="ongoing"]')).toBeNull()
expect(screen.getByText('等待回答')).toBeTruthy()
expect(screen.getByText('1 个子代理运行中')).toBeTruthy()
})
it('shows the green done dot on a finished search result row', () => {
render(<SearchResultItem
result={{ id: sid('result'), title: 'Done', workspace: 'Workspace', running: false, completed: true }}
result={{
id: sid('result'), title: 'Done', workspace: 'Workspace', running: false,
runningSubagentCount: 0, completed: true,
}}
currentId={undefined} onOpen={vi.fn()} t={t}
/>)
expect(screen.getByRole('treeitem').querySelector('[data-state="done"]')).not.toBeNull()
@@ -231,7 +275,8 @@ describe('workspace browser rows', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s-blank'), title: 'ignored', blank: true, running: false, completed: false, updatedAt: 0,
id: sid('s-blank'), title: 'ignored', blank: true, running: false,
runningSubagentCount: 0, completed: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -257,7 +302,8 @@ describe('workspace browser rows', () => {
const onFork = vi.fn()
const onArchive = vi.fn()
const node: SessionNode = {
id: sid('s1'), title: 'One', blank: false, running: false, completed: false, updatedAt: 0,
id: sid('s1'), title: 'One', blank: false, running: false,
runningSubagentCount: 0, completed: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />)
@@ -290,7 +336,8 @@ describe('workspace browser rows', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s1'), title: 'Hovered', blank: false, running: true, completed: false, updatedAt: 0,
id: sid('s1'), title: 'Hovered', blank: false, running: true,
runningSubagentCount: 0, completed: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -321,7 +368,7 @@ describe('workspace browser rows', () => {
try {
const node: SessionNode = {
id: sid(pendingInteraction), title: 'Needs input', blank: false,
pendingInteraction, running: true, completed: false, updatedAt: 0,
pendingInteraction, running: true, runningSubagentCount: 0, completed: false, updatedAt: 0,
}
const view = render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -347,7 +394,8 @@ describe('workspace browser rows', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s1'), title: 'Quiet', blank: false, running: false, completed: false, updatedAt: 0,
id: sid('s1'), title: 'Quiet', blank: false, running: false,
runningSubagentCount: 0, 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} />)
@@ -364,7 +412,8 @@ describe('workspace browser rows', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s1'), title: 'Done', blank: false, running: false, completed: true, updatedAt: 0,
id: sid('s1'), title: 'Done', blank: false, running: false,
runningSubagentCount: 0, completed: true, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -379,7 +428,8 @@ 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', blank: false, running: false, completed: false, updatedAt: 0,
id: sid('s1'), title: 'Drag me', blank: false, running: false,
runningSubagentCount: 0, completed: false, updatedAt: 0,
}
const inactive = dragProps()
const { rerender } = render(

View File

@@ -95,18 +95,35 @@ describe('deriveGroups', () => {
it('hides subagent-origin sessions without hiding ordinary forks', () => {
const parent = summary('parent', 1)
const fork = { ...summary('fork', 2), parentId: parent.id }
const subagent = { ...summary('subagent', 3), parentId: parent.id, origin: 'subagent' as const }
const sessions = { ...list(parent, fork, subagent), current: subagent.id }
const subagent = {
...summary('subagent', 3), parentId: parent.id, origin: 'subagent' as const, running: true,
}
const grandchild = {
...summary('grandchild', 4), parentId: subagent.id, origin: 'subagent' as const, running: true,
}
const fork = { ...summary('fork', 2), parentId: subagent.id }
const forkChild = {
...summary('fork-child', 5), parentId: fork.id, origin: 'subagent' as const, running: true,
}
const sessions = { ...list(parent, fork, subagent, grandchild, forkChild), current: subagent.id }
const groups = deriveGroups(
sessions,
[workspace('first', ['parent', 'fork', 'subagent'])],
[workspace('first', ['parent', 'fork', 'subagent', 'grandchild', 'fork-child'])],
noArchive,
view(['first']),
)
expect(groups[0]!.sessions.map(node => node.id)).toEqual([parent.id, fork.id])
expect(groups[0]!.sessionCount).toBe(2)
expect(groups[0]!.sessions[0]).toMatchObject({ running: false, runningSubagentCount: 2 })
expect(groups[0]!.sessions[1]).toMatchObject({ running: false, runningSubagentCount: 1 })
expect(deriveFlat(sessions, noArchive).map(node => [node.id, node.runningSubagentCount])).toEqual([
[fork.id, 1], [parent.id, 2],
])
expect(deriveSearchResults(
sessions, [workspace('first', ['parent', 'fork'])], 'parent', noArchive,
{ items: [], hasMore: false }, 10,
).items[0]).toMatchObject({ id: parent.id, runningSubagentCount: 2 })
})
it('ignores fork lineage and sorts every ungrouped session as a top-level row', () => {
@@ -274,6 +291,7 @@ describe('deriveSearchResults', () => {
title: 'Needle title',
workspace: 'Alpha',
running: false,
runningSubagentCount: 0,
pendingInteraction: 'plan-review',
completed: false,
snippet: 'title session body excerpt',
@@ -283,6 +301,7 @@ describe('deriveSearchResults', () => {
title: 'Ordinary title',
workspace: 'Needle Workspace',
running: false,
runningSubagentCount: 0,
completed: false,
},
{
@@ -290,6 +309,7 @@ describe('deriveSearchResults', () => {
title: 'content-hit',
workspace: 'c',
running: false,
runningSubagentCount: 0,
completed: false,
snippet: 'body needle excerpt',
},