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:
kingwl
2026-08-02 23:37:17 +08:00
102 changed files with 1586 additions and 291 deletions

View File

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

View File

@@ -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('一次性任务不支持后续消息')
})
})