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/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