1
0
Эх сурвалжийг харах

refactor(client): keep feature helpers with consumers

imccyu 1 сар өмнө
parent
commit
3d1c0af60b

+ 0 - 50
packages/api/session-controller/src/client/sessions/subagent-lineage.ts

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

+ 0 - 54
packages/api/session-controller/tests/subagent-lineage.client.spec.ts

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

+ 13 - 1
packages/client/ui-approval/src/client/contract/slots.ts

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

+ 0 - 18
packages/client/ui-conversation/src/client/pending-composer.ts

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

+ 6 - 3
packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx

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

+ 10 - 9
packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx

@@ -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 - 2
packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx

@@ -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 - 0
packages/client/ui-subagent/src/client/subagent-lineage.ts

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

+ 31 - 0
packages/client/ui-subagent/tests/subagent-lineage.client.spec.ts

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

+ 13 - 1
packages/client/ui-user-questions/src/client/contract/slots.ts

@@ -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 - 0
packages/client/ui-workspace/src/client/subagent-lineage.ts

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

+ 7 - 4
packages/client/ui-workspace/src/client/tree.ts

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

+ 30 - 0
packages/client/ui-workspace/tests/subagent-lineage.client.spec.ts

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