feat(client): surface subagent activity in sidebar
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/runtime/README.md
|
||||
README.md: 8ac29a4258bbd7456b20c61e547d48c570e84d27
|
||||
README.zh.md: 0e065e43ecc571e68d3976d2100eb43959cb2e3d
|
||||
README.md: 00427f33b1dfc23b157c8fe4cfefb42cf313ee66
|
||||
README.zh.md: 0a27602a4408792e7f02ecd3995aeb5946e81aab
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,6 +22,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线
|
||||
|
||||
SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-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 镜像
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
53
packages/client/runtime/tests/subagent-lineage.spec.ts
Normal file
53
packages/client/runtime/tests/subagent-lineage.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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。待处理的用户交互仍是主要行标记,而后代活动会作为独立的悬停与无障碍状态保留。运行时仍保留隐藏行,供对话、标题与已寻址传输状态使用。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user