Merge master into subagent usage branch
# Conflicts: # .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml # .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md # .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md # apps/web/tests/snapshots/subagent-conversation/tree.expected.md # apps/web/tests/subagent-conversation.e2e.ts # packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx
This commit is contained in:
@@ -24,6 +24,7 @@
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-primitives",
|
||||
@@ -40,6 +41,7 @@
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
@@ -51,7 +53,9 @@
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
|
||||
@@ -8,7 +8,8 @@ import type {
|
||||
import {
|
||||
IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { NS } from './locales.ts'
|
||||
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'
|
||||
@@ -26,7 +27,7 @@ export interface SubagentCatalogInjected {
|
||||
|
||||
/** Full props for the session-header catalog action. */
|
||||
export type SubagentCatalogActionProps =
|
||||
PropsRuntime<'conversation.session.header.actions'> & SubagentCatalogInjected
|
||||
PropsRuntime<'conversation.session.header.actions'> & SubagentCatalogInjected & PropsLocale<typeof NS>
|
||||
|
||||
interface CatalogRowsProps {
|
||||
parentSessionId: SessionId
|
||||
@@ -42,11 +43,14 @@ interface CatalogRowsProps {
|
||||
closeCatalog: () => void
|
||||
}
|
||||
|
||||
function diagnosticReason(entry: Extract<CatalogEntry, { kind: 'diagnostic' }>): string {
|
||||
function diagnosticReason(
|
||||
entry: Extract<CatalogEntry, { kind: 'diagnostic' }>,
|
||||
t: TranslateNS<typeof NS>,
|
||||
): string {
|
||||
switch (entry.reason) {
|
||||
case 'corrupt': return '会话记录损坏'
|
||||
case 'unsupported': return '子代理记录版本不受支持'
|
||||
case 'unavailable': return '会话记录暂不可用'
|
||||
case 'corrupt': return t('diagnostic.corrupt')
|
||||
case 'unsupported': return t('diagnostic.unsupported')
|
||||
case 'unavailable': return t('diagnostic.unavailable')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,19 +94,26 @@ function activityDuration(
|
||||
}
|
||||
|
||||
/** Format a non-negative duration to seconds without dropping larger units. */
|
||||
function formatDuration(ms: number): string {
|
||||
function formatDuration(ms: number, t: TranslateNS<typeof NS>): string {
|
||||
const totalSeconds = Math.floor(Math.max(0, ms) / 1_000)
|
||||
const seconds = totalSeconds % 60
|
||||
const totalMinutes = Math.floor(totalSeconds / 60)
|
||||
const minutes = totalMinutes % 60
|
||||
const hours = Math.floor(totalMinutes / 60)
|
||||
if (hours > 0) {
|
||||
return `${hours}小时${String(minutes).padStart(2, '0')}分${String(seconds).padStart(2, '0')}秒`
|
||||
return t('duration.hours', {
|
||||
hours,
|
||||
minutes: String(minutes).padStart(2, '0'),
|
||||
seconds: String(seconds).padStart(2, '0'),
|
||||
})
|
||||
}
|
||||
if (totalMinutes > 0) {
|
||||
return `${totalMinutes}分${String(seconds).padStart(2, '0')}秒`
|
||||
return t('duration.minutes', {
|
||||
minutes: totalMinutes,
|
||||
seconds: String(seconds).padStart(2, '0'),
|
||||
})
|
||||
}
|
||||
return `${seconds}秒`
|
||||
return t('duration.seconds', { seconds })
|
||||
}
|
||||
|
||||
/** Aggregate the complete subagent-only descendant subtree from flat summaries. */
|
||||
@@ -135,28 +146,30 @@ function CatalogLoadingRows({
|
||||
parentSessionId,
|
||||
summaries,
|
||||
level,
|
||||
t,
|
||||
}: {
|
||||
parentSessionId: SessionId
|
||||
summaries: Readonly<Record<SessionId, SessionSummary>>
|
||||
level: number
|
||||
t: TranslateNS<typeof NS>
|
||||
}) {
|
||||
const children = Object.values(summaries).filter(summary => (
|
||||
summary.origin === 'subagent' && summary.parentId === parentSessionId
|
||||
))
|
||||
if (children.length === 0) return <div className={css.notice}>正在加载子代理…</div>
|
||||
if (children.length === 0) return <div className={css.notice}>{t('loading.label')}</div>
|
||||
return children.map(summary => (
|
||||
<div key={summary.id} className={css.node}>
|
||||
<div
|
||||
role="treeitem"
|
||||
aria-disabled="true"
|
||||
aria-level={level}
|
||||
aria-label="正在加载子代理"
|
||||
aria-label={t('loading.aria')}
|
||||
className={`${css.row} ${css.disabled} ${css.loadingRow}`}
|
||||
>
|
||||
<span className={css.disclosureSpace} />
|
||||
<StateDot state={summary.running ? 'ongoing' : 'done'} />
|
||||
<span className={css.content}>
|
||||
<span className={css.label}>正在加载子代理…</span>
|
||||
<span className={css.label}>{t('loading.label')}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,8 +179,8 @@ function CatalogLoadingRows({
|
||||
/** Render one catalog level and recurse only through explicitly expanded rows. */
|
||||
function CatalogRows({
|
||||
parentSessionId, catalog, catalogs, summaries, expanded, level, now,
|
||||
openChild, refresh, toggleBranch, closeCatalog,
|
||||
}: CatalogRowsProps) {
|
||||
openChild, refresh, toggleBranch, closeCatalog, t,
|
||||
}: CatalogRowsProps & { t: TranslateNS<typeof NS> }) {
|
||||
const emptyLoading = catalog.state === 'loading' && catalog.entries.length === 0
|
||||
return (
|
||||
<>
|
||||
@@ -176,24 +189,25 @@ function CatalogRows({
|
||||
parentSessionId={parentSessionId}
|
||||
summaries={summaries}
|
||||
level={level}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{catalog.state === 'error' && (
|
||||
<div className={css.error}>
|
||||
<span>{catalog.error?.message ?? '无法加载子代理'}</span>
|
||||
<span>{catalog.error?.message ?? t('load.error')}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.refresh}
|
||||
onClick={() => { refresh(parentSessionId) }}
|
||||
>
|
||||
<IconRefreshOutline14 />
|
||||
重试
|
||||
{t('retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{catalog.entries.map((entry) => {
|
||||
if (entry.kind === 'diagnostic') {
|
||||
const reason = diagnosticReason(entry)
|
||||
const reason = diagnosticReason(entry, t)
|
||||
return (
|
||||
<div key={entry.id} className={css.node}>
|
||||
<div
|
||||
@@ -222,8 +236,8 @@ function CatalogRows({
|
||||
|| (childCatalog.state === 'loading' && childCatalog.entries.length === 0)
|
||||
const summary = summaries[entry.id]
|
||||
const label = entry.label ?? entry.id
|
||||
const mode = entry.mode === 'one-shot' ? '一次性' : '可继续'
|
||||
const activity = entry.activity === 'running' ? '正在运行' : '当前未运行'
|
||||
const mode = entry.mode === 'one-shot' ? t('mode.oneShot') : t('mode.continuable')
|
||||
const activity = entry.activity === 'running' ? t('activity.running') : t('activity.inactive')
|
||||
const secondary = [summary?.title, mode, activity]
|
||||
.filter(value => value !== undefined)
|
||||
.join(' · ')
|
||||
@@ -236,7 +250,7 @@ function CatalogRows({
|
||||
)
|
||||
const metrics = [
|
||||
totalTokens === undefined ? undefined : `${formatTokens(totalTokens)} tok`,
|
||||
durationMs === undefined ? undefined : formatDuration(durationMs),
|
||||
durationMs === undefined ? undefined : formatDuration(durationMs, t),
|
||||
].filter(value => value !== undefined).join(' · ')
|
||||
|
||||
const open = (): void => {
|
||||
@@ -282,7 +296,7 @@ function CatalogRows({
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className={`${css.disclosure} ${isExpanded ? css.disclosureOpen : ''}`}
|
||||
aria-label={`${isExpanded ? '收起' : '展开'} ${label} 的下级子代理`}
|
||||
aria-label={t(isExpanded ? 'branch.collapse' : 'branch.expand', { label })}
|
||||
onClick={toggle}
|
||||
>
|
||||
<IconChevronRightOutline14 />
|
||||
@@ -309,6 +323,7 @@ function CatalogRows({
|
||||
parentSessionId={entry.id}
|
||||
summaries={summaries}
|
||||
level={level + 1}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
@@ -324,6 +339,7 @@ function CatalogRows({
|
||||
refresh={refresh}
|
||||
toggleBranch={toggleBranch}
|
||||
closeCatalog={closeCatalog}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -338,10 +354,10 @@ function CatalogRows({
|
||||
/**
|
||||
* Render the current session's direct catalog and lazily expanded descendants.
|
||||
* @param props - session standard props plus catalog navigation actions.
|
||||
* @returns The action only after a non-empty catalog arrives.
|
||||
* @returns The action while the catalog is pending or summaries establish descendants.
|
||||
*/
|
||||
export function SubagentCatalogAction({
|
||||
sessionId, useSessions, openChild, refresh, setCatalogOpen,
|
||||
sessionId, useSessions, openChild, refresh, setCatalogOpen, t,
|
||||
}: SubagentCatalogActionProps) {
|
||||
const catalogs = useSessions(state => state.subagentsByParent)
|
||||
const summaries = useSessions(state => state.byId)
|
||||
@@ -359,6 +375,20 @@ export function SubagentCatalogAction({
|
||||
// The catalog can arrive before the session-list baseline; never undercount
|
||||
// the already-visible direct rows during that short bootstrap window.
|
||||
const descendantCount = Math.max(healthy.length, descendants.count)
|
||||
const totalCountKey = descendantCount === 1 ? 'count.total.one' : 'count.total.other'
|
||||
const runningCountKey = descendantCount === 1 ? 'count.running.one' : 'count.running.other'
|
||||
// Session summaries can announce membership before the descriptor-backed catalog catches up.
|
||||
// Keep that entry point visible through disabled loading rows; only catalog rows are navigable.
|
||||
const summaryBackedLoading = descendants.count > 0
|
||||
&& (catalog === undefined || (catalog.state === 'ready' && catalog.entries.length === 0))
|
||||
const presentedCatalog: SubagentCatalogSnapshot | undefined = summaryBackedLoading
|
||||
? {
|
||||
entries: [],
|
||||
parentAvailable: catalog?.parentAvailable ?? false,
|
||||
state: 'loading',
|
||||
error: null,
|
||||
}
|
||||
: catalog
|
||||
|
||||
const observeCatalog = (parentSessionId: SessionId, next: boolean): void => {
|
||||
if (next) observedCatalogs.current.add(parentSessionId)
|
||||
@@ -432,7 +462,8 @@ export function SubagentCatalogAction({
|
||||
observedCatalogs.current.clear()
|
||||
}, [])
|
||||
|
||||
const visible = catalog !== undefined && (catalog.state !== 'ready' || catalog.entries.length > 0)
|
||||
const visible = presentedCatalog !== undefined
|
||||
&& (presentedCatalog.state !== 'ready' || presentedCatalog.entries.length > 0)
|
||||
useEffect(() => {
|
||||
if (visible || !open) return
|
||||
setOpen(false)
|
||||
@@ -476,7 +507,7 @@ export function SubagentCatalogAction({
|
||||
className={css.trigger}
|
||||
aria-haspopup="tree"
|
||||
aria-expanded={open}
|
||||
aria-label={`${descendantCount} 个子代理${descendants.running ? ',正在运行' : ''}`}
|
||||
aria-label={t(descendants.running ? runningCountKey : totalCountKey, { count: descendantCount })}
|
||||
onClick={() => { changeOpen(!open) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'ArrowDown') return
|
||||
@@ -488,14 +519,14 @@ export function SubagentCatalogAction({
|
||||
<span className={css.activitySlot}>
|
||||
{descendants.running && <StateDot state="ongoing" />}
|
||||
</span>
|
||||
<span className={css.count}>{descendantCount} 个子代理</span>
|
||||
<span className={css.count}>{t(totalCountKey, { count: descendantCount })}</span>
|
||||
<IconChevronDownOutline14 className={open ? css.triggerOpen : undefined} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className={css.menu} role="tree" aria-label="子代理会话">
|
||||
<div className={css.menu} role="tree" aria-label={t('tree.aria')}>
|
||||
<CatalogRows
|
||||
parentSessionId={sessionId}
|
||||
catalog={catalog}
|
||||
catalog={presentedCatalog}
|
||||
catalogs={catalogs}
|
||||
summaries={summaries}
|
||||
expanded={expanded}
|
||||
@@ -505,6 +536,7 @@ export function SubagentCatalogAction({
|
||||
refresh={refresh}
|
||||
toggleBranch={toggleBranch}
|
||||
closeCatalog={() => { changeOpen(false) }}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { NS } from './locales.ts'
|
||||
import css from './SubagentReadOnlyComposer.module.css'
|
||||
|
||||
/** Why a catalog-addressed conversation cannot accept human input. */
|
||||
@@ -8,7 +9,7 @@ export interface SubagentReadOnlyMatch {
|
||||
|
||||
/** Full chain props after the read-only subagent selector accepts the owner currency. */
|
||||
export type SubagentReadOnlyComposerProps =
|
||||
PropsRuntime<'conversation.composer'> & { matched: SubagentReadOnlyMatch }
|
||||
PropsRuntime<'conversation.composer'> & { matched: SubagentReadOnlyMatch } & PropsLocale<typeof NS>
|
||||
|
||||
/**
|
||||
* Explain why the normal composer is unavailable for an addressed child.
|
||||
@@ -16,16 +17,14 @@ export type SubagentReadOnlyComposerProps =
|
||||
* @returns A read-only composer replacement.
|
||||
*/
|
||||
export function SubagentReadOnlyComposer({
|
||||
matched,
|
||||
}: Pick<SubagentReadOnlyComposerProps, 'matched'>) {
|
||||
matched, t,
|
||||
}: Pick<SubagentReadOnlyComposerProps, 'matched' | 't'>) {
|
||||
const oneShot = matched.reason === 'one-shot'
|
||||
return (
|
||||
<div className={css.frame} role="status">
|
||||
<strong>{oneShot ? '一次性子代理记录' : '此子代理暂时只读'}</strong>
|
||||
<strong>{t(oneShot ? 'readonly.oneShot.title' : 'readonly.title')}</strong>
|
||||
<span>
|
||||
{oneShot
|
||||
? '一次性任务不支持后续消息,可在这里查看完整执行记录。'
|
||||
: '父会话当前不在线,重新打开父会话后即可继续发送消息。'}
|
||||
{t(oneShot ? 'readonly.oneShot.body' : 'readonly.body')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -18,6 +18,15 @@ import { SubagentCatalogAction, type SubagentCatalogInjected } from './SubagentC
|
||||
import {
|
||||
SubagentReadOnlyComposer, type SubagentReadOnlyMatch,
|
||||
} from './SubagentReadOnlyComposer.tsx'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { en, NS, zh, type SubagentKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** Subagent catalog and read-only composer copy. */
|
||||
'subagent': SubagentKey
|
||||
}
|
||||
}
|
||||
|
||||
export type {
|
||||
SubagentCatalogActionProps, SubagentCatalogInjected,
|
||||
@@ -27,7 +36,7 @@ export type {
|
||||
} from './SubagentReadOnlyComposer.tsx'
|
||||
|
||||
/** Required services for references, conversation slots, and session navigation. */
|
||||
export const inject = ['slash', 'sessions', 'conversation', 'slots']
|
||||
export const inject = ['slash', 'sessions', 'conversation', 'slots', 'locale']
|
||||
|
||||
/** Claim the composer for one-shot history or an unavailable continuation owner. */
|
||||
function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null {
|
||||
@@ -42,6 +51,7 @@ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatc
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-subagent: dictionaries')
|
||||
const sessions = ctx.sessions
|
||||
// Child labels live on the session list (parentId lineage + displayTitle),
|
||||
// not the conversation snapshot — the list store is the zero-RPC candidate feed.
|
||||
@@ -98,6 +108,7 @@ export function apply(ctx: ClientContext): void {
|
||||
name: 'conversation.session.header.actions',
|
||||
id: 'subagent-catalog',
|
||||
order: 10,
|
||||
locale: NS,
|
||||
inject: catalogActions,
|
||||
}, SubagentCatalogAction),
|
||||
'ui-subagent: lazy descendant catalog action',
|
||||
@@ -106,6 +117,7 @@ export function apply(ctx: ClientContext): void {
|
||||
() => ctx.slots.register({
|
||||
name: 'conversation.composer',
|
||||
priority: -10,
|
||||
locale: NS,
|
||||
select: selectReadOnlySubagent,
|
||||
}, SubagentReadOnlyComposer),
|
||||
'ui-subagent: read-only addressed composer',
|
||||
|
||||
65
packages/client/ui-subagent/src/client/locales.ts
Normal file
65
packages/client/ui-subagent/src/client/locales.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/** `subagent` namespace dictionaries. */
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
export const NS = 'subagent'
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'diagnostic.corrupt': '会话记录损坏',
|
||||
'diagnostic.unsupported': '子代理记录版本不受支持',
|
||||
'diagnostic.unavailable': '会话记录暂不可用',
|
||||
'duration.seconds': '{seconds}秒',
|
||||
'duration.minutes': '{minutes}分{seconds}秒',
|
||||
'duration.hours': '{hours}小时{minutes}分{seconds}秒',
|
||||
'loading.label': '正在加载子代理…',
|
||||
'loading.aria': '正在加载子代理',
|
||||
'load.error': '无法加载子代理',
|
||||
'retry': '重试',
|
||||
'mode.oneShot': '一次性',
|
||||
'mode.continuable': '可继续',
|
||||
'activity.running': '正在运行',
|
||||
'activity.inactive': '当前未运行',
|
||||
'branch.collapse': '收起 {label} 的下级子代理',
|
||||
'branch.expand': '展开 {label} 的下级子代理',
|
||||
'count.total.one': '{count} 个子代理',
|
||||
'count.total.other': '{count} 个子代理',
|
||||
'count.running.one': '{count} 个子代理,正在运行',
|
||||
'count.running.other': '{count} 个子代理,正在运行',
|
||||
'tree.aria': '子代理会话',
|
||||
'readonly.oneShot.title': '一次性子代理记录',
|
||||
'readonly.title': '此子代理暂时只读',
|
||||
'readonly.oneShot.body': '一次性任务不支持后续消息,可在这里查看完整执行记录。',
|
||||
'readonly.body': '父会话当前不在线,重新打开父会话后即可继续发送消息。',
|
||||
} as const
|
||||
|
||||
/** English dictionary, key-identical to the Chinese source of truth. */
|
||||
export const en: Record<SubagentKey, string> = {
|
||||
'diagnostic.corrupt': 'corrupted session record',
|
||||
'diagnostic.unsupported': 'unsupported subagent record version',
|
||||
'diagnostic.unavailable': 'session record temporarily unavailable',
|
||||
'duration.seconds': '{seconds}s',
|
||||
'duration.minutes': '{minutes}m {seconds}s',
|
||||
'duration.hours': '{hours}h {minutes}m {seconds}s',
|
||||
'loading.label': 'Loading subagents…',
|
||||
'loading.aria': 'Loading subagents',
|
||||
'load.error': 'Unable to load subagents',
|
||||
'retry': 'Retry',
|
||||
'mode.oneShot': 'one-shot',
|
||||
'mode.continuable': 'continuable',
|
||||
'activity.running': 'running',
|
||||
'activity.inactive': 'not running',
|
||||
'branch.collapse': 'Collapse {label} descendants',
|
||||
'branch.expand': 'Expand {label} descendants',
|
||||
'count.total.one': '{count} subagent',
|
||||
'count.total.other': '{count} subagents',
|
||||
'count.running.one': '{count} subagent running',
|
||||
'count.running.other': '{count} subagents running',
|
||||
'tree.aria': 'Subagent sessions',
|
||||
'readonly.oneShot.title': 'One-shot subagent record',
|
||||
'readonly.title': 'This subagent is read-only for now',
|
||||
'readonly.oneShot.body': 'One-shot tasks do not accept follow-ups; review the full execution record here.',
|
||||
'readonly.body': 'The parent session is offline; reopen it to continue sending messages.',
|
||||
}
|
||||
|
||||
/** Key domain of the `subagent` namespace (zh is the source of truth). */
|
||||
export type SubagentKey = keyof typeof zh
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import {
|
||||
SubagentCatalogAction, type SubagentCatalogInjected,
|
||||
} from '../src/client/SubagentCatalogAction.tsx'
|
||||
@@ -85,6 +86,7 @@ async function fullBench(sessions: SessionSummary[]) {
|
||||
ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } })
|
||||
ctx.provide('sessions', face)
|
||||
await provideSlotFaces(ctx)
|
||||
await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
return { source: captured!, face, ctx }
|
||||
}
|
||||
@@ -111,7 +113,7 @@ const req = (query: string) =>
|
||||
|
||||
describe('apply', () => {
|
||||
it('declares the services it binds', () => {
|
||||
expect(inject).toEqual(['slash', 'sessions', 'conversation', 'slots'])
|
||||
expect(inject).toEqual(['slash', 'sessions', 'conversation', 'slots', 'locale'])
|
||||
})
|
||||
|
||||
it('registers the "@" subagent source; disposal frees the name (HMR safety)', async () => {
|
||||
@@ -119,6 +121,7 @@ describe('apply', () => {
|
||||
await ctx.plugin(SlashService).await()
|
||||
ctx.provide('sessions', sessionsWith(FAMILY))
|
||||
await provideSlotFaces(ctx)
|
||||
await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await()
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const slash = ctx.get('slash') as SlashService
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type {
|
||||
SessionId, SessionListState, SessionSummary, SubagentCatalogSnapshot,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
SubagentCatalogAction, type SubagentCatalogActionProps,
|
||||
} from '../src/client/SubagentCatalogAction.tsx'
|
||||
import { SubagentReadOnlyComposer } from '../src/client/SubagentReadOnlyComposer.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
@@ -18,6 +20,7 @@ afterEach(() => {
|
||||
const PARENT = 'parent' as SessionId
|
||||
const CHILD = 'child' as SessionId
|
||||
const GRANDCHILD = 'grandchild' as SessionId
|
||||
const t: SubagentCatalogActionProps['t'] = makeTranslate(zh)
|
||||
|
||||
function catalog(over: Partial<SubagentCatalogSnapshot> = {}): SubagentCatalogSnapshot {
|
||||
return {
|
||||
@@ -70,6 +73,7 @@ function props(
|
||||
openChild: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
setCatalogOpen: vi.fn(),
|
||||
t,
|
||||
} as unknown as SubagentCatalogActionProps
|
||||
}
|
||||
|
||||
@@ -155,6 +159,24 @@ describe('SubagentCatalogAction', () => {
|
||||
expect(input.setCatalogOpen).toHaveBeenLastCalledWith(PARENT, false)
|
||||
})
|
||||
|
||||
it('selects singular count keys for one descendant', () => {
|
||||
const base = props(catalog({
|
||||
entries: [{
|
||||
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
|
||||
activity: 'running', hasChildren: false,
|
||||
}],
|
||||
}), {}, {
|
||||
[CHILD]: {
|
||||
...summary(CHILD, Date.now()), parentId: PARENT, origin: 'subagent', running: true,
|
||||
},
|
||||
})
|
||||
const translate = vi.fn(base.t)
|
||||
render(<SubagentCatalogAction {...base} t={translate} />)
|
||||
|
||||
expect(translate).toHaveBeenCalledWith('count.running.one', { count: 1 })
|
||||
expect(translate).toHaveBeenCalledWith('count.total.one', { count: 1 })
|
||||
})
|
||||
|
||||
it('supports trigger/menu keyboard traversal, Escape focus restore, and outside close', async () => {
|
||||
const input = props(catalog())
|
||||
render(<SubagentCatalogAction {...input} />)
|
||||
@@ -416,6 +438,32 @@ describe('SubagentCatalogAction', () => {
|
||||
expect(failed.refresh).toHaveBeenCalledWith(PARENT)
|
||||
})
|
||||
|
||||
it('keeps known descendants reachable while their catalog is absent or stale-empty', () => {
|
||||
const second = 'child-2' as SessionId
|
||||
const summaries = {
|
||||
[CHILD]: {
|
||||
...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' as const,
|
||||
},
|
||||
[second]: {
|
||||
...summary(second, 1), parentId: PARENT, origin: 'subagent' as const, running: true,
|
||||
},
|
||||
}
|
||||
const absent = props(undefined, {}, summaries)
|
||||
const view = render(<SubagentCatalogAction {...absent} />)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: '2 个子代理,正在运行' })
|
||||
fireEvent.click(trigger)
|
||||
expect(absent.setCatalogOpen).toHaveBeenCalledWith(PARENT, true)
|
||||
expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2)
|
||||
expect(absent.openChild).not.toHaveBeenCalled()
|
||||
|
||||
const staleEmpty = props(catalog({ entries: [] }), {}, summaries)
|
||||
view.rerender(<SubagentCatalogAction {...staleEmpty} />)
|
||||
expect(screen.getByRole('button', { name: '2 个子代理,正在运行' })).toBeTruthy()
|
||||
expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2)
|
||||
expect(staleEmpty.openChild).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders empty loading and fallback error states without focusable rows', async () => {
|
||||
const loading = props(catalog({ entries: [], state: 'loading' }))
|
||||
const view = render(<SubagentCatalogAction {...loading} />)
|
||||
@@ -469,12 +517,12 @@ describe('SubagentCatalogAction', () => {
|
||||
|
||||
describe('SubagentReadOnlyComposer', () => {
|
||||
it('explains the exact missing-parent recovery path', () => {
|
||||
render(<SubagentReadOnlyComposer matched={{ reason: 'parent-unavailable' }} />)
|
||||
render(<SubagentReadOnlyComposer matched={{ reason: 'parent-unavailable' }} t={t} />)
|
||||
expect(screen.getByRole('status').textContent).toContain('父会话当前不在线')
|
||||
})
|
||||
|
||||
it('explains that one-shot histories never accept follow-ups', () => {
|
||||
render(<SubagentReadOnlyComposer matched={{ reason: 'one-shot' }} />)
|
||||
render(<SubagentReadOnlyComposer matched={{ reason: 'one-shot' }} t={t} />)
|
||||
expect(screen.getByRole('status').textContent).toContain('一次性任务不支持后续消息')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user