refactor(client): keep feature helpers with consumers
This commit is contained in:
parent
9f2f498e7c
commit
3d1c0af60b
13 changed files with 205 additions and 142 deletions
|
|
@ -1,50 +0,0 @@
|
|||
/**
|
||||
* 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-api-session-controller/client/sessions/subagent-lineage
|
||||
*/
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
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
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionSummary } from '../src/client/index.ts'
|
||||
import { indexSubagentDescendants } from '../src/client/index.ts'
|
||||
|
||||
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 })
|
||||
})
|
||||
})
|
||||
|
|
@ -4,9 +4,21 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
|||
import type {
|
||||
PropsLocale, PropsRenderSlots, PropsRuntime,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { settlePendingComposer } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ApprovalKey } from '../locales.ts'
|
||||
|
||||
/* jscpd:ignore-start -- Approval and Question intentionally own independent pending-settlement lifecycles. */
|
||||
function settlePendingComposer(settle: () => void, failureMessage: string): Promise<void> {
|
||||
try {
|
||||
settle()
|
||||
return Promise.resolve()
|
||||
} catch (error) {
|
||||
return Promise.reject(error instanceof Error
|
||||
? error
|
||||
: new Error(failureMessage, { cause: error }))
|
||||
}
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-session/client' {
|
||||
interface SessionPendingInteractionMap {
|
||||
/** Pending approval request. */
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
/** Shared settlement mechanics for composer takeovers backed by a pending waterfall. */
|
||||
|
||||
/**
|
||||
* Run one pending composer settlement and preserve non-Error rejection causes.
|
||||
* @param settle - synchronous Promise resolver or rejector invocation.
|
||||
* @param failureMessage - message used when the resolver throws a non-Error value.
|
||||
* @returns completion or a rejection carrying the original failure.
|
||||
*/
|
||||
export function settlePendingComposer(settle: () => void, failureMessage: string): Promise<void> {
|
||||
try {
|
||||
settle()
|
||||
return Promise.resolve()
|
||||
} catch (error) {
|
||||
return Promise.reject(error instanceof Error
|
||||
? error
|
||||
: new Error(failureMessage, { cause: error }))
|
||||
}
|
||||
}
|
||||
|
|
@ -41,7 +41,6 @@ import {
|
|||
IconPlusOutline16, Modal,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import type { Translate } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import css from './DirectoryBrowser.module.css'
|
||||
|
||||
|
|
@ -63,9 +62,13 @@ export interface DirectoryBrowserProps {
|
|||
t: Translate
|
||||
}
|
||||
|
||||
/** Failure text: the Host business message when typed, else the throw's text. */
|
||||
/** Failure text from the injected directory operation. */
|
||||
function failureText(error: unknown): string {
|
||||
if (error instanceof DirectoryBrowseError) return error.rpcError.message
|
||||
if (error !== null && typeof error === 'object' && 'rpcError' in error) {
|
||||
const rpcError = error.rpcError
|
||||
if (rpcError !== null && typeof rpcError === 'object' && 'message' in rpcError
|
||||
&& typeof rpcError.message === 'string') return rpcError.message
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import type { DirectoryListing } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import { DirectoryBrowser } from '../src/client/DirectoryBrowser.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
|
@ -81,7 +80,7 @@ function listingFor(path?: string): DirectoryListing {
|
|||
}
|
||||
const found = tree[target]
|
||||
if (found === undefined) {
|
||||
throw new DirectoryBrowseError({ code: 'directory-unreadable', message: `cannot list ${target}`, details: { path: target } })
|
||||
throw new Error(`cannot list ${target}`)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
|
@ -543,7 +542,7 @@ describe('DirectoryBrowser', () => {
|
|||
it('re-parks focus on the edit zone when a failed pick unmounts a dot-revealed row', async () => {
|
||||
const listDirectory = vi.fn(async (path?: string) => {
|
||||
if (path === `${HOME}/.config`) {
|
||||
throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path } })
|
||||
throw new Error('denied')
|
||||
}
|
||||
return listingFor(path)
|
||||
})
|
||||
|
|
@ -564,7 +563,7 @@ describe('DirectoryBrowser', () => {
|
|||
it('leaves focus on a surviving row when its pick fails', async () => {
|
||||
const listDirectory = vi.fn(async (path?: string) => {
|
||||
if (path === DOCS) {
|
||||
throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path } })
|
||||
throw new Error('denied')
|
||||
}
|
||||
return listingFor(path)
|
||||
})
|
||||
|
|
@ -587,7 +586,7 @@ describe('DirectoryBrowser', () => {
|
|||
// The initial open lists home through the absent-path form; only the
|
||||
// parent leg names HOME explicitly.
|
||||
if (path === HOME) {
|
||||
throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'parent gone', details: { path } })
|
||||
throw new Error('parent gone')
|
||||
}
|
||||
return listingFor(path)
|
||||
})
|
||||
|
|
@ -1286,7 +1285,9 @@ describe('DirectoryBrowser', () => {
|
|||
|
||||
it('keeps path entry available when the home listing fails', async () => {
|
||||
const listDirectory = vi.fn(async (): Promise<DirectoryListing> => {
|
||||
throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'home unreadable', details: { path: HOME } })
|
||||
throw Object.assign(new Error('directory browse failed: directory-unreadable: home unreadable'), {
|
||||
rpcError: { code: 'directory-unreadable', message: 'home unreadable', details: { path: HOME } },
|
||||
})
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('home unreadable') })
|
||||
|
|
@ -1346,7 +1347,7 @@ describe('DirectoryBrowser', () => {
|
|||
fireEvent.click(rowButton(screen.getByRole('listitem')))
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
b.listDirectory.mockImplementation(async () => {
|
||||
throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path: HOME } })
|
||||
throw new Error('denied')
|
||||
})
|
||||
fireEvent.click(within(screen.getByRole('navigation')).getByRole('button', { name: 'browser.home' }))
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
|
||||
|
|
@ -1453,7 +1454,7 @@ describe('DirectoryBrowser', () => {
|
|||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
b.listDirectory.mockImplementation(async () => {
|
||||
throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path: DOCS } })
|
||||
throw new Error('denied')
|
||||
})
|
||||
fireEvent.click(rowButton(screen.getByRole('listitem')))
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
|
||||
|
|
@ -1574,7 +1575,7 @@ describe('DirectoryBrowser', () => {
|
|||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
b.createDirectory.mockRejectedValueOnce(
|
||||
new DirectoryBrowseError({ code: 'directory-exists', message: 'taken already', details: { path: `${HOME}/x` } }))
|
||||
new Error('taken already'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||
expect(screen.getByText('browser.createIn:browser.home')).toBeTruthy()
|
||||
const input = screen.getByLabelText('browser.folderName')
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import {
|
|||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import {
|
||||
indexSubagentDescendants, type SessionListState, type SessionProjectionMap,
|
||||
type SessionSummary, type SubagentCatalogSnapshot,
|
||||
type SessionListState, type SessionProjectionMap, type SessionSummary,
|
||||
type SubagentCatalogSnapshot,
|
||||
} from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
|
|
@ -17,6 +17,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|||
import type {} from '@deepseek-ai/dsh-subagent/client'
|
||||
import type {} from '@deepseek-ai/dsh-token-meter/client'
|
||||
import css from './SubagentHeaderLineage.module.css'
|
||||
import { indexSubagentDescendants } from './subagent-lineage.ts'
|
||||
|
||||
type CatalogEntry = SubagentCatalogSnapshot['entries'][number]
|
||||
type Catalogs = SessionListState['subagentsByParent']
|
||||
|
|
|
|||
46
packages/client/ui-subagent/src/client/subagent-lineage.ts
Normal file
46
packages/client/ui-subagent/src/client/subagent-lineage.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/** UI Subagent-owned projection of descendant counts from Session summaries. */
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
interface LineageEntry {
|
||||
readonly id: SessionId
|
||||
readonly parentId?: SessionId
|
||||
readonly origin?: 'subagent'
|
||||
readonly running: boolean
|
||||
}
|
||||
|
||||
/** Descendant counts for one possible parent Session. */
|
||||
export interface SubagentDescendantSummary {
|
||||
readonly count: number
|
||||
readonly runningCount: number
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- UI Subagent and UI Workspace independently project their own views. */
|
||||
/**
|
||||
* Index uninterrupted subagent descendants under each ancestor.
|
||||
* @param summaries - Session summaries keyed by id.
|
||||
* @returns descendant totals keyed by possible parent id.
|
||||
*/
|
||||
export function indexSubagentDescendants(
|
||||
summaries: Readonly<Record<SessionId, LineageEntry>>,
|
||||
): 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: LineageEntry | 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
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { indexSubagentDescendants } from '../src/client/subagent-lineage.ts'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
|
||||
describe('UI Subagent descendant projection', () => {
|
||||
it('counts nested running descendants and stops at ordinary forks', () => {
|
||||
const owner = { id: sid('owner'), running: false }
|
||||
const child = { id: sid('child'), parentId: owner.id, origin: 'subagent' as const, running: false }
|
||||
const grandchild = { id: sid('grandchild'), parentId: child.id, origin: 'subagent' as const, running: true }
|
||||
const fork = { id: sid('fork'), parentId: child.id, running: false }
|
||||
const forkChild = { id: sid('fork-child'), parentId: fork.id, origin: 'subagent' as const, running: true }
|
||||
const result = indexSubagentDescendants(Object.fromEntries(
|
||||
[owner, child, grandchild, fork, forkChild].map(item => [item.id, item]),
|
||||
))
|
||||
expect(result.get(owner.id)).toEqual({ count: 2, runningCount: 1 })
|
||||
expect(result.get(child.id)).toEqual({ count: 1, runningCount: 1 })
|
||||
expect(result.get(fork.id)).toEqual({ count: 1, runningCount: 1 })
|
||||
})
|
||||
|
||||
it('terminates cycles and retains missing-parent aggregates', () => {
|
||||
const cycleA = { id: sid('a'), parentId: sid('b'), origin: 'subagent' as const, running: false }
|
||||
const cycleB = { id: sid('b'), parentId: sid('a'), origin: 'subagent' as const, running: false }
|
||||
const orphan = { id: sid('orphan'), parentId: sid('missing'), origin: 'subagent' as const, running: true }
|
||||
const result = indexSubagentDescendants({ [cycleA.id]: cycleA, [cycleB.id]: cycleB, [orphan.id]: orphan })
|
||||
expect(result.get(cycleA.id)?.count).toBe(2)
|
||||
expect(result.get(cycleB.id)?.count).toBe(2)
|
||||
expect(result.get(sid('missing'))).toEqual({ count: 1, runningCount: 1 })
|
||||
})
|
||||
})
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
/** Question composer props and one pending Remote waterfall response. */
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// The client module declares the conversation.composer SlotMap entry required by PropsRuntime.
|
||||
import { settlePendingComposer } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
AskUserQuestionAnswer, AskUserQuestionItem,
|
||||
|
|
@ -23,6 +22,19 @@ type QuestionItem = AskUserQuestionItem
|
|||
/** One option the asker offered on a question. */
|
||||
type QuestionOption = NonNullable<QuestionItem['options']>[number]
|
||||
|
||||
/* jscpd:ignore-start -- Question and Approval intentionally own independent pending-settlement lifecycles. */
|
||||
function settlePendingComposer(settle: () => void, failureMessage: string): Promise<void> {
|
||||
try {
|
||||
settle()
|
||||
return Promise.resolve()
|
||||
} catch (error) {
|
||||
return Promise.reject(error instanceof Error
|
||||
? error
|
||||
: new Error(failureMessage, { cause: error }))
|
||||
}
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* A request narrowed to the `plan-review` presentation intent: everything the
|
||||
* decision card renders and answers with, so the panel never re-reads the
|
||||
|
|
|
|||
46
packages/client/ui-workspace/src/client/subagent-lineage.ts
Normal file
46
packages/client/ui-workspace/src/client/subagent-lineage.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/** UI Workspace-owned projection of descendant counts from Session summaries. */
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
interface LineageEntry {
|
||||
readonly id: SessionId
|
||||
readonly parentId?: SessionId
|
||||
readonly origin?: 'subagent'
|
||||
readonly running: boolean
|
||||
}
|
||||
|
||||
/** Descendant counts for one possible parent Session. */
|
||||
export interface SubagentDescendantSummary {
|
||||
readonly count: number
|
||||
readonly runningCount: number
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- UI Subagent and UI Workspace independently project their own views. */
|
||||
/**
|
||||
* Index uninterrupted subagent descendants under each ancestor.
|
||||
* @param summaries - Session summaries keyed by id.
|
||||
* @returns descendant totals keyed by possible parent id.
|
||||
*/
|
||||
export function indexSubagentDescendants(
|
||||
summaries: Readonly<Record<SessionId, LineageEntry>>,
|
||||
): 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: LineageEntry | 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
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
|
@ -4,14 +4,17 @@
|
|||
* remains visible.
|
||||
*/
|
||||
import {
|
||||
indexSubagentDescendants, type SessionListState,
|
||||
type SessionSearchResultItem, type SessionSummary, type SubagentDescendantSummary,
|
||||
type SessionListState, type SessionSearchResultItem, type SessionSummary,
|
||||
} from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client'
|
||||
import type {
|
||||
SessionPendingInteractionBase,
|
||||
} from '@deepseek-ai/dsh-client-ui-session/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import {
|
||||
indexSubagentDescendants, type SubagentDescendantSummary,
|
||||
} from './subagent-lineage.ts'
|
||||
|
||||
/** Group key for Sessions outside every Workspace. */
|
||||
export const UNGROUPED_KEY = ''
|
||||
|
|
@ -104,8 +107,8 @@ interface Group {
|
|||
*/
|
||||
export function workspaceLabel(cwd: string | undefined): string {
|
||||
if (cwd === undefined || cwd === '') return ''
|
||||
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
|
||||
return base !== undefined && base !== '' ? base : cwd
|
||||
const base = workspaceTitleOf(cwd)
|
||||
return base !== '' ? base : cwd
|
||||
}
|
||||
|
||||
/** Recency comparator: newest first, id as the deterministic tiebreak (ids are unique per group). */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { indexSubagentDescendants } from '../src/client/subagent-lineage.ts'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
|
||||
describe('UI Workspace descendant projection', () => {
|
||||
it('counts nested running descendants and stops at ordinary forks', () => {
|
||||
const root = { id: sid('root'), running: false }
|
||||
const child = { id: sid('child'), parentId: root.id, origin: 'subagent' as const, running: true }
|
||||
const leaf = { id: sid('leaf'), parentId: child.id, origin: 'subagent' as const, running: false }
|
||||
const fork = { id: sid('fork'), parentId: child.id, running: false }
|
||||
const result = indexSubagentDescendants(Object.fromEntries(
|
||||
[root, child, leaf, fork].map(item => [item.id, item]),
|
||||
))
|
||||
expect(result.get(root.id)).toEqual({ count: 2, runningCount: 1 })
|
||||
expect(result.get(child.id)).toEqual({ count: 1, runningCount: 0 })
|
||||
expect(result.has(fork.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('terminates cycles and retains missing-parent aggregates', () => {
|
||||
const a = { id: sid('a'), parentId: sid('b'), origin: 'subagent' as const, running: false }
|
||||
const b = { id: sid('b'), parentId: sid('a'), origin: 'subagent' as const, running: false }
|
||||
const orphan = { id: sid('orphan'), parentId: sid('missing'), origin: 'subagent' as const, running: true }
|
||||
const result = indexSubagentDescendants({ [a.id]: a, [b.id]: b, [orphan.id]: orphan })
|
||||
expect(result.get(a.id)?.count).toBe(2)
|
||||
expect(result.get(b.id)?.count).toBe(2)
|
||||
expect(result.get(sid('missing'))).toEqual({ count: 1, runningCount: 1 })
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue