refactor: limit reference path depth to two levels and streamline mention handling in workbench components

This commit is contained in:
hy committed 2026-09-09 17:11:36 +08:00
1 parent 6c6c50eb7a
commit 86c04ea931
8 files changed
+142 -330

No files matched your search

+3 -3
View File
@@ -419,11 +419,11 @@ Composer 只有智能模式,不再提供 AI / 搜索切换。
#### WB-SCOPE-001 `@` 候选来源规则
- 系统行为:输入 `@` 后,根据当前关键字返回目录、文件和网页资料候选。浏览态(无关键字)以层级下钻展示:首屏为各工作地址根与全局网页;点击目录进入该层子项(不做树形展开箭头),顶部面包屑可返回上级;目录行提供「引用」以插入目录 token,文件/网页点击即插入。每一层与搜索结果均按页懒加载,单次最多 50 条,滚到底加载下一批。有关键字时改为全局搜索扁平结果(仍每页最多 50),副文案保留路径。本地索引只补充索引状态,不决定候选是否出现。候选浮层按“文件”和“网页”两组展示(下钻进目录后通常仅“文件”组)。键盘上下移动高亮项时,列表滚动跟随使当前项可见;Enter 在浏览态对目录为进入,对文件/网页为插入;搜索态对目录亦为插入。未创建或未选中伙伴时 Composer 为空态,不开放 `@` 发送路径。
- 系统行为:输入 `@` 后,根据当前关键字返回目录、文件和网页资料候选。本地候选相对每个工作地址根**最多收录两级路径**(根为第 0 级,可引用工作地址本身;其下第一层、第二层的目录与文件进入列表;第三层及更深不出现)。列表为「文件 / 网页」两组**平铺**,无展开、无点进目录;点击任一项即插入对应 token。工作地址根、目录、文件、网页均可选中。候选按页懒加载,单次最多 50 条,滚到底加载下一批;有关键字时在上述扁平集合内子串过滤后再分页。本地索引只补充索引状态,不决定候选是否出现。键盘上下移动高亮项时列表滚动跟随使当前项可见;Enter 插入当前高亮项。未创建或未选中伙伴时 Composer 为空态,不开放 `@` 发送路径。
- 验收:
- `WB-SCOPE-001-AC1` 未选中伙伴时不把资料选择当作正常发送路径暴露给用户。
- `WB-SCOPE-001-AC2` 已选中伙伴后,首屏可见工作地址根与全局网页;点进目录后可见该层子项;只展示“文件”和“网页”两组,目录通过图标与「引用」识别。
- `WB-SCOPE-001-AC3` 在上一次索引或候选打开后新增、修改或删除本地文件时,再次进入对应目录或搜索必须反映真实目录;新增或变更但未索引的文件仍可选择,并显示“未索引”。
- `WB-SCOPE-001-AC2` 已选中伙伴后,候选可同时出现工作地址根、根下两级内的目录/文件与全局网页;只展示“文件”和“网页”两组;点击目录与点击文件一样直接插入,不进入子层。
- `WB-SCOPE-001-AC3` 在上一次索引或候选打开后新增、修改或删除本地文件时,再次打开 `@`(或搜索)必须反映真实目录;深度超过两级的路径不出现;新增或变更但未索引的文件仍可选择,并显示“未索引”。
- `WB-SCOPE-001-AC4` 浏览与搜索均按页加载且单次不超过 50 条;滚到底可继续加载;键盘高亮超出可视区时滚动条跟随。
#### WB-SCOPE-002 `@` 插入与删除规则
+2 -2
View File
@@ -248,8 +248,8 @@ tests/case/workbench/
- 测试类型:`e2e`
- What:`@` 参考资料候选覆盖全部文件地址的当前真实本地目录 / 文件与全局网页资料、目录 / 文件 / 网页 token 插入、多 token 共存、键盘删除、去重和空候选提示。
- Why:显式参考资料是用户控制 AI 上下文的核心入口;未 `@` 时知识库默认全部已索引文件地址 + 全局网页。
- How:创建伙伴;通过设置页添加 `tests/fixtures/workspace` 为文件地址,等待真实文件扫描并准备索引状态;添加真实 web source 并等待真实网页索引完成;随后直接在该目录磁盘中新建文件,不触发“检查更新”或“更新索引”;输入 `@` 先见工作地址根与网页,点进目录下钻查看子项(单页最多 50);搜索关键字走全局分页;选择文件、目录(引用)、网页;选中即追加,同一资料才去重;通过 Backspace 删除 token。对话里不再切换工作空间,因此不测跨工作区清理。
- Oracle:候选来源和发送范围对齐 `WB-SCOPE-001`(含下钻与分页 `AC2`/`AC4`)、`WB-CORE-002`、`WB-CORE-004-AC1`;真实目录与新增未索引文件对齐 `WB-SCOPE-001-AC3`;token 插入、共存、发送参数、键盘删除和图标对齐 `WB-SCOPE-002`;去重和空候选对齐 `WB-SCOPE-003`、`WB-SCOPE-004`。废弃的 `WB-BLOCKER-002` 不测。
- How:创建伙伴;通过设置页添加 `tests/fixtures/workspace` 为文件地址,等待真实文件扫描并准备索引状态;添加真实 web source 并等待真实网页索引完成;随后直接在该目录磁盘中新建文件,不触发“检查更新”或“更新索引”;输入 `@` 触发最多两级路径的平铺候选(每页最多 50);选择文件、目录、网页均为点击即插入;同一资料才去重;通过 Backspace 删除 token。对话里不再切换工作空间,因此不测跨工作区清理。
- Oracle:候选来源和发送范围对齐 `WB-SCOPE-001`(含两级平铺与分页 `AC2`/`AC4`)、`WB-CORE-002`、`WB-CORE-004-AC1`;真实目录与新增未索引文件对齐 `WB-SCOPE-001-AC3`;token 插入、共存、发送参数、键盘删除和图标对齐 `WB-SCOPE-002`;去重和空候选对齐 `WB-SCOPE-003`、`WB-SCOPE-004`。废弃的 `WB-BLOCKER-002` 不测。
- Not:不验证底层 KB 检索排序质量,也不验证 Settings 文件地址配置细节。`WB-CORE-004-AC2`、`WB-CORE-004-AC3`(宽松补充 vs 严谨缺口)由 AI live 承担。
#### `right-panel-layout-and-tabs`
-4
View File
@@ -2231,10 +2231,6 @@ export function registerHandlers(services: {
listWorkspaceFileIndexRecords: (workspaceId) =>
workspaceStore.listWorkspaceFileIndexRecords(workspaceId),
listDirectory: (input) => workspaceService.listDirectory(input),
scanWorkspaceTree: async (input) => {
const scanResult = await workspaceService.scanWorkspaceTree(input)
return { workspace: scanResult.workspace, entries: scanResult.entries }
},
listWebSources: () => webSourceStore.listWebSources(),
normalizeWorkspaceRoot: (rootPath) => workspaceService.normalizeWorkspaceRoot(rootPath)
},
+88 -125
View File
@@ -5,17 +5,16 @@ import type {
KbAvailability,
ListReferenceItemsRequest,
ReferenceItemListResponse,
ReferenceListParent,
SelectedReference,
WorkspaceFileIndexRecord,
WorkspaceSource,
WorkspaceTreeEntry
} from '../../../shared/types'
import { REFERENCE_ITEM_PAGE_SIZE as DEFAULT_PAGE_SIZE } from '../../../shared/types'
import {
buildWebReferenceItem,
buildWorkspaceReferenceItemsFromCurrentTree
} from './selection'
import { buildWebReferenceItem } from './selection'
/** 相对工作地址根的最大路径深度(根为 0,根下第一层为 1,再下一层为 2) */
const MAX_REFERENCE_PATH_DEPTH = 2
const isIndexedWorkspaceFile = (record?: WorkspaceFileIndexRecord): boolean =>
Boolean(record && record.index_status === 'indexed' && record.document_id)
@@ -37,6 +36,9 @@ const normalizeRelativePath = (value: string): string =>
.replace(/\\/g, '/')
.replace(/^\/+|\/+$/g, '')
const pathDepth = (relativePath: string): number =>
normalizeRelativePath(relativePath).split('/').filter(Boolean).length
const matchesQuery = (reference: SelectedReference, query: string): boolean => {
const normalized = query.trim().toLowerCase()
if (!normalized) return true
@@ -104,32 +106,20 @@ const buildFileReferenceFromEntry = (input: {
}
}
const buildReferencesFromEntries = (input: {
const entryToReference = (input: {
workspace: WorkspaceSource
entries: WorkspaceTreeEntry[]
entry: WorkspaceTreeEntry
records: WorkspaceFileIndexRecord[]
}): SelectedReference[] => {
const directories = input.entries.filter((entry) => entry.kind === 'directory')
const files = input.entries.filter((entry) => entry.kind === 'file')
return [
...directories.map((entry) =>
buildDirectoryReferenceFromRecords({
}): SelectedReference =>
input.entry.kind === 'directory'
? buildDirectoryReferenceFromRecords({
workspace: input.workspace,
name: entry.name,
relativePath: entry.relative_path,
absolutePath: entry.absolute_path,
name: input.entry.name,
relativePath: input.entry.relative_path,
absolutePath: input.entry.absolute_path,
records: input.records
})
),
...files.map((entry) =>
buildFileReferenceFromEntry({
workspace: input.workspace,
entry,
records: input.records
})
)
]
}
: buildFileReferenceFromEntry(input)
const paginate = (
items: SelectedReference[],
@@ -156,10 +146,6 @@ export type ReferenceListDeps = {
rootPath: string
relativePath?: string
}) => Promise<WorkspaceTreeEntry[]>
scanWorkspaceTree: (input: {
workspaceId: string
rootPath: string
}) => Promise<{ workspace: WorkspaceSource; entries: WorkspaceTreeEntry[] }>
listWebSources: () => Promise<Parameters<typeof buildWebReferenceItem>[0][]>
normalizeWorkspaceRoot: (rootPath: string) => Promise<{
normalizedRootPath: string
@@ -168,111 +154,43 @@ export type ReferenceListDeps = {
}>
}
const resolveParent = (parent?: ReferenceListParent): ReferenceListParent =>
parent && typeof parent === 'object' && 'type' in parent ? parent : { type: 'roots' }
const allowKind = (
kind: SelectedReference['kind'],
filters: Set<SelectedReference['kind']> | null
): boolean => !filters || filters.has(kind)
export const listReferenceItemsPage = async (
/** 收集单个工作地址内深度 ≤ 2 的扁平候选(含根目录本身) */
const collectWorkspaceShallowReferences = async (
deps: ReferenceListDeps,
request: ListReferenceItemsRequest,
workspace: WorkspaceSource,
kindFilters: Set<SelectedReference['kind']> | null
): Promise<ReferenceItemListResponse> => {
const query = String(request.query || '').trim()
const offset = Number(request.offset) || 0
const limit = Number(request.limit) || DEFAULT_PAGE_SIZE
const parent = resolveParent(request.parent)
): Promise<SelectedReference[]> => {
if (!workspace.root_path) return []
const normalized = await deps.normalizeWorkspaceRoot(workspace.root_path)
const records = await deps.listWorkspaceFileIndexRecords(workspace.id)
if (query) {
const workspaces = await deps.listWorkspaces()
const local: SelectedReference[] = []
for (const workspace of workspaces) {
if (!workspace.root_path) continue
if (!allowKind('directory', kindFilters) && !allowKind('file', kindFilters)) break
const [scanResult, records] = await Promise.all([
deps.scanWorkspaceTree({ workspaceId: workspace.id, rootPath: workspace.root_path }),
deps.listWorkspaceFileIndexRecords(workspace.id)
])
local.push(
...buildWorkspaceReferenceItemsFromCurrentTree({
workspace: scanResult.workspace,
entries: scanResult.entries,
records
})
)
if (normalized.kind === 'file') {
if (!allowKind('file', kindFilters)) return []
const name = path.basename(normalized.normalizedRootPath)
const record = records.find((item) => item.relative_path === name)
const file: FileReference = {
kind: 'file',
workspaceId: workspace.id,
nodeId: toEntryId(workspace.id, 'file', name),
name,
relativePath: name,
absolutePath: normalized.normalizedRootPath,
fileExt: path.extname(name).slice(1) || undefined,
kbDocumentIds:
isIndexedWorkspaceFile(record) && record?.document_id ? [record.document_id] : [],
kbAvailability: isIndexedWorkspaceFile(record) ? 'indexed' : 'not_indexed'
}
const web = allowKind('web', kindFilters)
? (await deps.listWebSources()).map(buildWebReferenceItem)
: []
const matched = [...local, ...web]
.filter((item) => allowKind(item.kind, kindFilters))
.filter((item) => matchesQuery(item, query))
return paginate(matched, offset, limit)
return [file]
}
if (parent.type === 'web') {
if (!allowKind('web', kindFilters)) {
return paginate([], offset, limit)
}
const web = (await deps.listWebSources()).map(buildWebReferenceItem)
return paginate(web, offset, limit)
}
if (parent.type === 'workspace') {
const workspaces = await deps.listWorkspaces()
const workspace = workspaces.find((item) => item.id === parent.workspaceId)
if (!workspace?.root_path) return paginate([], offset, limit)
const relativePath = normalizeRelativePath(parent.relativePath)
const [entries, records] = await Promise.all([
deps.listDirectory({
workspaceId: workspace.id,
rootPath: workspace.root_path,
relativePath
}),
deps.listWorkspaceFileIndexRecords(workspace.id)
])
const items = buildReferencesFromEntries({ workspace, entries, records }).filter((item) =>
allowKind(item.kind, kindFilters)
)
return paginate(items, offset, limit)
}
// roots: workspace roots then web
const workspaces = await deps.listWorkspaces()
const roots: SelectedReference[] = []
for (const workspace of workspaces) {
if (!workspace.root_path) continue
const normalized = await deps.normalizeWorkspaceRoot(workspace.root_path)
const records = await deps.listWorkspaceFileIndexRecords(workspace.id)
if (normalized.kind === 'file') {
if (!allowKind('file', kindFilters)) continue
const name = path.basename(normalized.normalizedRootPath)
roots.push({
kind: 'file',
workspaceId: workspace.id,
nodeId: toEntryId(workspace.id, 'file', name),
name,
relativePath: name,
absolutePath: normalized.normalizedRootPath,
fileExt: path.extname(name).slice(1) || undefined,
kbDocumentIds: [],
kbAvailability: 'not_indexed'
})
const record = records.find((item) => item.relative_path === name)
if (isIndexedWorkspaceFile(record) && record?.document_id) {
const last = roots[roots.length - 1]
if (last && last.kind === 'file') {
last.kbDocumentIds = [record.document_id]
last.kbAvailability = 'indexed'
}
}
continue
}
if (!allowKind('directory', kindFilters)) continue
roots.push(
const items: SelectedReference[] = []
if (allowKind('directory', kindFilters)) {
items.push(
buildDirectoryReferenceFromRecords({
workspace,
name: normalized.displayName,
@@ -282,8 +200,53 @@ export const listReferenceItemsPage = async (
})
)
}
const depth1 = await deps.listDirectory({
workspaceId: workspace.id,
rootPath: workspace.root_path,
relativePath: ''
})
const depth1Dirs: WorkspaceTreeEntry[] = []
for (const entry of depth1) {
if (pathDepth(entry.relative_path) > MAX_REFERENCE_PATH_DEPTH) continue
if (!allowKind(entry.kind, kindFilters)) continue
items.push(entryToReference({ workspace, entry, records }))
if (entry.kind === 'directory') depth1Dirs.push(entry)
}
for (const dir of depth1Dirs) {
const depth2 = await deps.listDirectory({
workspaceId: workspace.id,
rootPath: workspace.root_path,
relativePath: dir.relative_path
})
for (const entry of depth2) {
if (pathDepth(entry.relative_path) > MAX_REFERENCE_PATH_DEPTH) continue
if (!allowKind(entry.kind, kindFilters)) continue
items.push(entryToReference({ workspace, entry, records }))
}
}
return items
}
export const listReferenceItemsPage = async (
deps: ReferenceListDeps,
request: ListReferenceItemsRequest,
kindFilters: Set<SelectedReference['kind']> | null
): Promise<ReferenceItemListResponse> => {
const query = String(request.query || '').trim()
const offset = Number(request.offset) || 0
const limit = Number(request.limit) || DEFAULT_PAGE_SIZE
const workspaces = await deps.listWorkspaces()
const local: SelectedReference[] = []
for (const workspace of workspaces) {
local.push(...(await collectWorkspaceShallowReferences(deps, workspace, kindFilters)))
}
const web = allowKind('web', kindFilters)
? (await deps.listWebSources()).map(buildWebReferenceItem)
: []
return paginate([...roots, ...web], offset, limit)
const all = [...local, ...web].filter((item) => matchesQuery(item, query))
return paginate(all, offset, limit)
}
@@ -12,7 +12,6 @@ import type {
LLMConfig,
LlmRemoteModel,
ReferenceKind,
ReferenceListParent,
SelectedReference,
WorkbenchModelSelection
} from '../../../../shared/types'
@@ -132,10 +131,6 @@ const WorkbenchComposer: React.FC<WorkbenchComposerProps> = ({
const attachmentPreviewUrlsRef = useRef<Record<string, string>>({})
const [mentionQuery, setMentionQuery] = useState<string | null>(null)
const [mentionSessionId, setMentionSessionId] = useState<number | null>(null)
const [mentionParent, setMentionParent] = useState<ReferenceListParent>({ type: 'roots' })
const [mentionBreadcrumbs, setMentionBreadcrumbs] = useState<
{ label: string; parent: ReferenceListParent }[]
>([{ label: '资料', parent: { type: 'roots' } }])
const [mentionOptions, setMentionOptions] = useState<SelectedReference[]>([])
const [mentionLoading, setMentionLoading] = useState(false)
const [mentionLoadingMore, setMentionLoadingMore] = useState(false)
@@ -157,8 +152,6 @@ const WorkbenchComposer: React.FC<WorkbenchComposerProps> = ({
mentionQueryRef.current = null
setMentionQuery(null)
setMentionSessionId(null)
setMentionParent({ type: 'roots' })
setMentionBreadcrumbs([{ label: '资料', parent: { type: 'roots' } }])
setMentionOptions([])
setMentionHasMore(false)
mentionHasMoreRef.current = false
@@ -180,12 +173,7 @@ const WorkbenchComposer: React.FC<WorkbenchComposerProps> = ({
}, [])
const fetchMentionPage = useCallback(
async (input: {
parent: ReferenceListParent
query: string
offset: number
append: boolean
}): Promise<void> => {
async (input: { query: string; offset: number; append: boolean }): Promise<void> => {
const requestSeq = ++mentionRequestSeqRef.current
if (input.append) {
setMentionLoadingMore(true)
@@ -197,7 +185,6 @@ const WorkbenchComposer: React.FC<WorkbenchComposerProps> = ({
const referenceKinds: ReferenceKind[] = COMPOSER_REFERENCE_KINDS
const response = await window.api.referenceItemList({
referenceKinds,
parent: input.query ? undefined : input.parent,
query: input.query || undefined,
offset: input.offset,
limit: REFERENCE_ITEM_PAGE_SIZE
@@ -384,8 +371,6 @@ const WorkbenchComposer: React.FC<WorkbenchComposerProps> = ({
setMentionQuery(context.query)
if (isNewMentionSession) {
mentionSessionSequenceRef.current += 1
setMentionParent({ type: 'roots' })
setMentionBreadcrumbs([{ label: '资料', parent: { type: 'roots' } }])
setMentionOptions([])
setMentionSessionId(mentionSessionSequenceRef.current)
}
@@ -483,57 +468,21 @@ const WorkbenchComposer: React.FC<WorkbenchComposerProps> = ({
useEffect(() => {
if (mentionSessionId === null || mentionQuery === null) return
void fetchMentionPage({
parent: mentionParent,
query: mentionQuery,
offset: 0,
append: false
})
}, [fetchMentionPage, mentionParent, mentionQuery, mentionSessionId])
const navigateMentionParent = useCallback(
(parent: ReferenceListParent, breadcrumbs: { label: string; parent: ReferenceListParent }[]) => {
setMentionParent(parent)
setMentionBreadcrumbs(breadcrumbs)
setMentionOptions([])
setMentionActiveIndex(0)
},
[]
)
const enterMentionDirectory = useCallback(
(reference: SelectedReference & { kind: 'directory' }) => {
navigateMentionParent(
{
type: 'workspace',
workspaceId: reference.workspaceId,
relativePath: reference.relativePath
},
[
...mentionBreadcrumbs,
{
label: reference.name,
parent: {
type: 'workspace',
workspaceId: reference.workspaceId,
relativePath: reference.relativePath
}
}
]
)
},
[mentionBreadcrumbs, navigateMentionParent]
)
}, [fetchMentionPage, mentionQuery, mentionSessionId])
const loadMoreMentionOptions = useCallback(() => {
if (mentionQuery === null) return
if (!mentionHasMoreRef.current || mentionLoadingMoreRef.current || mentionLoading) return
void fetchMentionPage({
parent: mentionParent,
query: mentionQuery,
offset: mentionNextOffsetRef.current,
append: true
})
}, [fetchMentionPage, mentionLoading, mentionParent, mentionQuery])
}, [fetchMentionPage, mentionLoading, mentionQuery])
useEffect(() => {
const handleSelectionChange = (): void => {
@@ -748,12 +697,7 @@ const WorkbenchComposer: React.FC<WorkbenchComposerProps> = ({
if (event.key === 'Enter') {
event.preventDefault()
const nextOption = mentionOptions[mentionActiveIndex]
if (!nextOption) return
if (nextOption.kind === 'directory' && !(mentionQuery || '').trim()) {
enterMentionDirectory(nextOption)
return
}
insertMentionScope(nextOption)
if (nextOption) insertMentionScope(nextOption)
return
}
if (event.key === 'Escape') {
@@ -896,24 +840,7 @@ const WorkbenchComposer: React.FC<WorkbenchComposerProps> = ({
options={mentionOptions}
activeIndex={mentionActiveIndex}
hasMore={mentionHasMore}
breadcrumbs={mentionBreadcrumbs}
searching={Boolean(mentionQuery.trim())}
onSelect={insertMentionScope}
onEnterDirectory={enterMentionDirectory}
onNavigate={(parent) => {
const index = mentionBreadcrumbs.findIndex(
(crumb) =>
crumb.parent.type === parent.type &&
(parent.type !== 'workspace' ||
(crumb.parent.type === 'workspace' &&
crumb.parent.workspaceId === parent.workspaceId &&
crumb.parent.relativePath === parent.relativePath))
)
navigateMentionParent(
parent,
index >= 0 ? mentionBreadcrumbs.slice(0, index + 1) : mentionBreadcrumbs
)
}}
onLoadMore={loadMoreMentionOptions}
/>
) : null}
@@ -1,28 +1,19 @@
import React, { useEffect, useRef } from 'react'
import { Empty, Spin } from 'antd'
import type { ReferenceListParent, SelectedReference } from '../../../../../shared/types'
import type { SelectedReference } from '../../../../../shared/types'
import { workbenchSelectors } from '../../../testing/selectors'
import { getKbAvailabilityLabel } from '../../../utils/reference'
import ReferenceIcon from './ReferenceIcon'
import { getReferenceVisualSpec } from './referenceVisuals'
import { getScopeKey } from './utils'
type BreadcrumbItem = {
label: string
parent: ReferenceListParent
}
type ReferenceMentionPanelProps = {
loading: boolean
loadingMore: boolean
options: SelectedReference[]
activeIndex: number
hasMore: boolean
breadcrumbs: BreadcrumbItem[]
searching: boolean
onSelect: (reference: SelectedReference) => void
onEnterDirectory: (reference: SelectedReference & { kind: 'directory' }) => void
onNavigate: (parent: ReferenceListParent) => void
onLoadMore: () => void
}
@@ -33,6 +24,7 @@ type ReferenceMentionGroup = {
const getReferenceParentDisplayPath = (reference: SelectedReference): string => {
if (reference.kind === 'web') return reference.url
if (!reference.relativePath) return ''
const pathParts = reference.relativePath.split('/').filter(Boolean)
pathParts.pop()
return pathParts.join('/')
@@ -44,11 +36,7 @@ const ReferenceMentionPanel: React.FC<ReferenceMentionPanelProps> = ({
options,
activeIndex,
hasMore,
breadcrumbs,
searching,
onSelect,
onEnterDirectory,
onNavigate,
onLoadMore
}) => {
const scrollRef = useRef<HTMLDivElement>(null)
@@ -83,27 +71,6 @@ const ReferenceMentionPanel: React.FC<ReferenceMentionPanelProps> = ({
className="absolute inset-x-0 bottom-full mb-2 overflow-hidden rounded-[14px] border border-[#E5E4E0] bg-white px-2 py-2 shadow-[0_16px_36px_rgba(15,15,15,0.1)]"
data-testid={workbenchSelectors.mentionPanel}
>
{!searching && breadcrumbs.length > 0 ? (
<div className="mb-1 flex min-w-0 items-center gap-1 overflow-x-auto px-2.5 pb-1 text-[11px] text-[#9B9A97]">
{breadcrumbs.map((crumb, index) => (
<React.Fragment key={`${crumb.parent.type}-${index}`}>
{index > 0 ? <span className="text-[#D0CFCB]">/</span> : null}
<button
type="button"
className={`shrink-0 rounded px-0.5 hover:bg-[#F4F4F2] hover:text-[#57554F] ${
index === breadcrumbs.length - 1 ? 'font-medium text-[#787774]' : ''
}`}
onMouseDown={(event) => {
event.preventDefault()
onNavigate(crumb.parent)
}}
>
{crumb.label}
</button>
</React.Fragment>
))}
</div>
) : null}
<div
ref={scrollRef}
className="max-h-[180px] overflow-y-auto pr-1"
@@ -130,59 +97,37 @@ const ReferenceMentionPanel: React.FC<ReferenceMentionPanelProps> = ({
const active = activeIndex === optionIndex
const parentPath = getReferenceParentDisplayPath(reference)
const visualSpec = getReferenceVisualSpec(reference)
const isDirectory = reference.kind === 'directory'
return (
<div
<button
key={getScopeKey(reference)}
className={`flex h-8 w-full items-center gap-0.5 rounded-[8px] px-1.5 transition-colors ${
type="button"
className={`flex h-8 w-full items-center gap-[2px] rounded-[8px] px-2.5 text-left transition-colors ${
active ? 'bg-[#F1F1F0]' : 'hover:bg-[#F7F7F5]'
}`}
data-reference-kind={reference.kind}
data-mention-active={active ? 'true' : undefined}
onMouseDown={(event) => {
event.preventDefault()
onSelect(reference)
}}
>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-[2px] rounded-[6px] px-1 text-left"
onMouseDown={(event) => {
event.preventDefault()
if (isDirectory && !searching) {
onEnterDirectory(reference)
return
}
onSelect(reference)
}}
>
<ReferenceIcon reference={reference} size="sm" />
<span className="flex min-w-0 flex-1 items-center gap-2">
<span
className={`truncate text-[13px] font-normal leading-5 ${visualSpec.labelClassName}`}
>
{reference.name}
</span>
{parentPath ? (
<span className="truncate text-[11px] font-normal leading-4 text-[#B1B0AD]">
{parentPath}
</span>
) : null}
</span>
<span className="shrink-0 text-[10px] text-[#B1B0AD]">
{getKbAvailabilityLabel(reference.kbAvailability)}
</span>
</button>
{isDirectory && !searching ? (
<button
type="button"
className="shrink-0 rounded px-1.5 py-0.5 text-[11px] text-[#787774] hover:bg-[#EBEBEA] hover:text-[#37352F]"
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
onSelect(reference)
}}
<ReferenceIcon reference={reference} size="sm" />
<span className="flex min-w-0 flex-1 items-center gap-2">
<span
className={`truncate text-[13px] font-normal leading-5 ${visualSpec.labelClassName}`}
>
引用
</button>
) : null}
</div>
{reference.name}
</span>
{parentPath ? (
<span className="truncate text-[11px] font-normal leading-4 text-[#B1B0AD]">
{parentPath}
</span>
) : null}
</span>
<span className="shrink-0 text-[10px] text-[#B1B0AD]">
{getKbAvailabilityLabel(reference.kbAvailability)}
</span>
</button>
)
})}
</div>
@@ -201,4 +146,3 @@ const ReferenceMentionPanel: React.FC<ReferenceMentionPanelProps> = ({
}
export default ReferenceMentionPanel
export type { BreadcrumbItem }
+2 -7
View File
@@ -299,16 +299,11 @@ export interface ReferenceItemListResponse {
/** @ 候选单次拉取上限 */
export const REFERENCE_ITEM_PAGE_SIZE = 50
/** 浏览父级:根(工作地址 + 网页)/ 某工作地址目录 / 仅网页 */
export type ReferenceListParent =
| { type: 'roots' }
| { type: 'workspace'; workspaceId: string; relativePath: string }
| { type: 'web' }
/** 相对工作地址根最多收录的路径深度(根=0,其下两级=2) */
export const REFERENCE_ITEM_MAX_PATH_DEPTH = 2
export interface ListReferenceItemsRequest {
referenceKinds?: ReferenceKind[]
parent?: ReferenceListParent
/** 非空时全局搜索,忽略 parent 浏览位置 */
query?: string
offset?: number
limit?: number
@@ -121,21 +121,6 @@ describe('workbench conversation e2e', () => {
.first()
.isVisible()
.catch(() => false)
await mentionPanel
.getByRole('button')
.filter({ hasText: 'Workbench Web Fixture' })
.click()
const afterWeb = await getWorkbenchReferenceTokenTexts(page)
await appendWorkbenchInputText(page, '@')
await waitForWorkbenchMentionPanelText(page)
await mentionPanel
.locator('[data-reference-kind="directory"]')
.getByRole('button')
.filter({ hasText: setup.workspaceName })
.first()
.click()
await waitForWorkbenchMentionPanelText(page)
const docxIconPresent =
(await mentionPanel.locator('[data-reference-icon-kind="docx"]').count()) > 0
const pdfIconPresent =
@@ -144,13 +129,13 @@ describe('workbench conversation e2e', () => {
(await mentionPanel.locator('[data-reference-icon-kind="markdown"]').count()) > 0
const genericFileIconPresent =
(await mentionPanel
.locator('[data-reference-kind="file"]')
.getByRole('button')
.filter({ hasText: 'human-input.txt' })
.locator('[data-reference-icon-kind="file"]')
.count()) > 0
const markdownUsesGenericFileIcon =
(await mentionPanel
.locator('[data-reference-kind="file"]')
.getByRole('button')
.filter({ hasText: 'workbench-preview.md' })
.locator('[data-reference-icon-kind="file"]')
.count()) > 0
@@ -159,42 +144,44 @@ describe('workbench conversation e2e', () => {
.isVisible()
.catch(() => false)
const textFileIndexedVisible = await mentionPanel
.locator('[data-reference-kind="file"]')
.getByRole('button')
.filter({ hasText: 'human-input.txt' })
.filter({ hasText: '已索引' })
.first()
.isVisible()
.catch(() => false)
const markdownFileIndexedVisible = await mentionPanel
.locator('[data-reference-kind="file"]')
.getByRole('button')
.filter({ hasText: 'workbench-preview.md' })
.filter({ hasText: '已索引' })
.first()
.isVisible()
.catch(() => false)
// 点进「工作」子目录查看索引后新增文件
await mentionPanel
.locator('[data-reference-kind="directory"]')
.filter({ hasText: '工作' })
.getByRole('button')
.filter({ hasText: '工作' })
.first()
.click()
await waitForWorkbenchMentionPanelText(page)
const addedAfterIndexVisible = await mentionPanel
.locator('[data-reference-kind="file"]')
.getByRole('button')
.filter({ hasText: 'added-after-index.md' })
.first()
.isVisible()
.catch(() => false)
const addedAfterIndexUnindexedVisible = await mentionPanel
.locator('[data-reference-kind="file"]')
.getByRole('button')
.filter({ hasText: 'added-after-index.md' })
.filter({ hasText: '未索引' })
.first()
.isVisible()
.catch(() => false)
const localTextFileStillUnindexed = false
const localTextFileStillUnindexed = await mentionPanel
.getByRole('button')
.filter({ hasText: 'human-input.txt' })
.filter({ hasText: '未索引' })
.first()
.isVisible()
.catch(() => false)
await mentionPanel
.getByRole('button')
.filter({ hasText: 'Workbench Web Fixture' })
.click()
const afterWeb = await getWorkbenchReferenceTokenTexts(page)
const tokensBeforeSubmit = await getWorkbenchReferenceTokenTexts(page)
const composerReferenceIconKinds = await getWorkbenchLocator(
page,