refactor(client): keep feature helpers with consumers

This commit is contained in:
imccyu 2026-08-23 22:49:57 +08:00
parent 9f2f498e7c
commit 3d1c0af60b
13 changed files with 205 additions and 142 deletions

View file

@ -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
}

View file

@ -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 })
})
})

View file

@ -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. */

View file

@ -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 }))
}
}

View file

@ -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)
}

View file

@ -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')

View file

@ -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']

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

View file

@ -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 })
})
})

View file

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

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

View file

@ -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). */

View file

@ -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 })
})
})