feat(web): make produced-file overflow discoverable
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md
|
||||
README.md: 7d03e5faedda3ba8c9cc4cab6ca134d98dc7ec13
|
||||
README.zh.md: dfbbc7a39aa94aab438119a4f23ffb02da2daa3d
|
||||
README.md: 9cfcde9510c5d6814d5b42fd58a0520ea37b8679
|
||||
README.zh.md: 94dba9bb38725ddb3ef39d55a5f8a185a5008d18
|
||||
|
||||
@@ -6,7 +6,7 @@ Produced-files feature owner: registers the deliverables row a finished turn end
|
||||
|
||||
`deliverablesDefinition` folds each Turn's successful mutation calls into engine-published `DeliverablesTurnData`; `producedForClosing` reads that data with the closing Assistant seq. The vocabulary is the mutation tools' own follow-along `locations`, never the closing prose: a produced file is listed whether or not the model remembered to name it. A mutation is recognized by render intent, not tool name — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a new mutation tool joins by declaring what it does. Reads, deletes, and failed calls contribute nothing; a path appears once per Turn in first-seen order. The Conversation Location index owns Turn membership, so a Turn that mutates and then ends without content text cannot spill into the next Turn's row.
|
||||
|
||||
`ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label, up to six chips (basename text, full path as the `title`), and an explicit remainder count past the cap. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md).
|
||||
`ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label and one measured file lane. It shows the largest leading prefix that fits (up to six chips; basename text, full path as the `title`) while reserving the exact localized `+ N files` width, so the remainder stays visible without wrapping or horizontal scrolling. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. When files are hidden, a second-line **Show in folder** action opens the session workspace through that same owner path only while the page is loopback and the current Host handshake reports `canOpenPath`; direct remote Web and headless/container Linux Hosts omit the action by default. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md).
|
||||
|
||||
The closing prose carries the same vocabulary. This plugin provides the `chatFileMentions` service the chat view consults per closing message: `producedFileMentions` resolves an inline-code token by exact path, or by being exactly the basename of exactly one produced path — a basename two paths share stays inert rather than guessing, so a mention link can never open the wrong file or 404. A resolved mention keeps its code chip and takes the markdown sheet's link language — link-blue at rest, underlined on hover, exactly like URL-promoted inline code — with the full path as its `title`; mentions never render inside anchors or streaming text. Decision record: the [inline file mentions Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md).
|
||||
|
||||
@@ -21,3 +21,4 @@ None; this package neither assembles nor sends provider requests.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Mention matching is exact path or unique basename only.** A suffix mention (`out/index.html` written as `index.html` resolves; `deep/out/index.html` written as `out/index.html` does not) stays inert; widening the matcher is deferred until a real closing-message shape needs it.
|
||||
- **Native folder handoff targets the Host desktop.** A browser reached through a non-loopback authority omits the action, as does a deployment reporting no native opener. SSH forwarding that makes a remote Host look loopback-local must set the gateway's `nativeOpen: false`; so must a headless macOS/Windows Host, a WSL deployment without working Windows interop, or any Linux desktop whose display/opener probe is a false positive. Identifying the operator-visible desktop remains deployment policy.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
`deliverablesDefinition` 把每个 Turn 中成功的修改调用折叠进引擎发布的 `DeliverablesTurnData`;`producedForClosing` 结合收尾 Assistant 的 seq 读取这份数据。依据的是修改工具自身附带的 `locations`,而不是收尾正文:无论模型是否记得点名,产出文件都会被列出。修改操作按渲染意图而非工具名识别:diff 卡片,或 `kind` 为 `edit` 的通用卡片(即 `str_replace_editor` 的 insert 操作所呈现的形态);因此新的修改工具只需声明自身行为即可加入。读取、删除和失败的调用不贡献任何条目;同一路径在一个 Turn 内按首见顺序只出现一次。Conversation Location 索引拥有 Turn 成员关系,因此一个 Turn 即使先修改文件、随后没有正文内容就结束,也不会溢进下一个 Turn 的行里。
|
||||
|
||||
`ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个低调的标签、至多六个标签项(文本为文件名,完整路径作为 `title`),超出上限则显示一个明确的剩余计数。每个标签项经由属主提供的 `openFile` 打开——与工具行相同的 Host 打开器,chat 视图会把相对路径按会话 cwd 解析。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。
|
||||
`ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个低调的标签和一条经过测量的单行文件 lane。它展示能够放下的最大前缀(至多六个标签项;文本为文件名,完整路径作为 `title`),并为本地化后的精确 `+ N 个文件` 宽度预留空间,因此剩余计数始终可见,既不换行也不横向滚动。每个标签项经由属主提供的 `openFile` 打开——与工具行相同的 Host 打开器,chat 视图会把相对路径按会话 cwd 解析。存在隐藏文件时,第二行的**在文件夹中显示**也经由同一属主路径打开会话 workspace;它只在页面使用 loopback 且当前 Host 握手报告 `canOpenPath` 时出现,直接远程 Web 与 headless/容器 Linux Host 默认均省略该操作。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。
|
||||
|
||||
收尾正文承载同一份词表。本插件提供供 chat 视图按收尾消息查询的 `chatFileMentions` 服务:`producedFileMentions` 按精确路径解析行内代码 token,或当 token 恰好是且仅是一条产出路径的 basename 时解析——两条路径共享同一 basename 时,文本保持不可点击而不作猜测,因此提及链接永远不会打开错误的文件或 404。解析成功的提及保留代码标签,并采用 Markdown 样式表的链接样式:静止时为链接蓝色,悬停时显示下划线,与 URL 提升的行内代码完全一致——完整路径作为其 `title`;提及绝不会渲染在链接内部或流式文本中。决策记录:[行内文件提及 Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md)。
|
||||
|
||||
@@ -21,3 +21,4 @@
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **提及匹配只认精确路径或唯一 basename。**后缀式提及(`out/index.html` 写作 `index.html` 可解析;`deep/out/index.html` 写作 `out/index.html` 则不行)保持不可点击;等真实的收尾消息形态产生需求后再放宽匹配规则。
|
||||
- **原生文件夹交接以 Host 桌面为目标。**经非 loopback 权威访问的浏览器会省略该操作,报告没有原生打开器的部署也一样。若 SSH 转发让远端 Host 看似处于本机 loopback,部署必须为网关设置 `nativeOpen: false`;无界面的 macOS/Windows Host、Windows interop 不可用的 WSL,或 display/opener 探测误报的 Linux 桌面也必须这样配置。识别操作者实际可见的桌面仍属于部署策略。
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
@@ -48,6 +49,7 @@
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
@@ -56,6 +58,7 @@
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
|
||||
@@ -1,24 +1,39 @@
|
||||
/* Turn-tail produced-files row: a quiet label followed by wrapping file chips.
|
||||
Sits between the assistant body and its IconActions footer, so it reads as
|
||||
part of the answer rather than as another tool row. */
|
||||
/* Turn-tail produced-files summary: one measured chip lane plus an optional
|
||||
native-folder action below it. */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
column-gap: 8px;
|
||||
row-gap: 6px;
|
||||
margin-top: 16px;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.label {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.row {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* One produced file. A link by behavior (it opens the file), a chip by shape:
|
||||
full paths are long and several may wrap onto one row. */
|
||||
full paths are long, while the measured lane stays on one row. */
|
||||
.file {
|
||||
flex: 0 0 auto;
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -38,7 +53,53 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.file:focus-visible,
|
||||
.showFolder:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
/* Overflow count: the row never silently drops files it did not show. */
|
||||
.more {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.showFolder {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
justify-self: start;
|
||||
margin: 0;
|
||||
padding: 0 2px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: inherit;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.showFolder:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Exact browser-native probes for every candidate shown count. They share the
|
||||
visible styles but never affect layout, accessibility, or scroll width. */
|
||||
.measure {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
contain: strict;
|
||||
}
|
||||
|
||||
.probe {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
@@ -4,46 +4,146 @@
|
||||
// through the same openFile the tool rows use — the Host's own opener, on the
|
||||
// Host machine.
|
||||
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { basename } from './turn-deliverables.ts'
|
||||
import type { NS } from './locales.ts'
|
||||
import css from './ProducedFiles.module.css'
|
||||
|
||||
/** Files past this stay counted but unlisted: a refactor turn must not bury the answer. */
|
||||
const SHOWN = 6
|
||||
/** At most six chips compete for the one-line summary; every other path stays counted. */
|
||||
const SHOWN_LIMIT = 6
|
||||
|
||||
/**
|
||||
* Select the largest prefix whose measured chips and exact remainder fit.
|
||||
* @param available - usable width of the one-line file lane.
|
||||
* @param gap - computed flex gap between adjacent visible items.
|
||||
* @param chipWidths - measured widths for the candidate file chips.
|
||||
* @param moreWidthsByShown - exact localized remainder width for each shown count.
|
||||
* @returns Number of leading chips to render.
|
||||
*/
|
||||
export function fitProducedFiles(
|
||||
available: number,
|
||||
gap: number,
|
||||
chipWidths: readonly number[],
|
||||
moreWidthsByShown: readonly (number | undefined)[],
|
||||
): number {
|
||||
if (available <= 0) return chipWidths.length
|
||||
const prefix = [0]
|
||||
for (const width of chipWidths) prefix.push((prefix.at(-1) ?? 0) + width)
|
||||
for (let shown = chipWidths.length; shown >= 0; shown -= 1) {
|
||||
const more = moreWidthsByShown[shown]
|
||||
const items = shown + (more === undefined ? 0 : 1)
|
||||
const needed = (prefix[shown] ?? 0) + (more ?? 0) + Math.max(0, items - 1) * gap
|
||||
if (needed <= available) return shown
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/** Matched paths plus the opener and locale seats needed to present them. */
|
||||
export type ProducedFilesProps = Pick<TurnTailOwnerProps, 'openFile'> & {
|
||||
matched: readonly string[]
|
||||
/** True only when this loopback deployment exposes a user-visible native opener. */
|
||||
canOpenPath: boolean
|
||||
} & PropsLocale<typeof NS>
|
||||
|
||||
/** Slot-owned props before the connection capability is injected. */
|
||||
export type ProducedFilesSeatProps = Omit<ProducedFilesProps, 'canOpenPath'>
|
||||
|
||||
function moreLabel(t: ProducedFilesProps['t'], count: number): string {
|
||||
return count === 1 ? t('produced.moreOne') : t('produced.more', { count: String(count) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one turn's produced files as openable chips.
|
||||
* @param props - selector-matched paths, the chat view's file opener, and the locale seat.
|
||||
* @returns The produced-files row.
|
||||
*/
|
||||
export function ProducedFiles({ matched: paths, openFile, t }: ProducedFilesProps) {
|
||||
const shown = paths.slice(0, SHOWN)
|
||||
export function ProducedFiles({ matched: paths, openFile, canOpenPath, t }: ProducedFilesProps) {
|
||||
const limit = Math.min(paths.length, SHOWN_LIMIT)
|
||||
const [shownCount, setShownCount] = useState(limit)
|
||||
const rowRef = useRef<HTMLDivElement>(null)
|
||||
const chipProbes = useRef<Array<HTMLButtonElement | null>>([])
|
||||
const moreProbes = useRef<Array<HTMLSpanElement | null>>([])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const row = rowRef.current
|
||||
if (row === null) return
|
||||
const measure = (): void => {
|
||||
const styles = getComputedStyle(row)
|
||||
const gap = Number.parseFloat(styles.columnGap || styles.gap) || 0
|
||||
const chips = chipProbes.current.slice(0, limit)
|
||||
.map(probe => probe?.getBoundingClientRect().width ?? 0)
|
||||
const more = Array.from({ length: limit + 1 }, (_, candidate) =>
|
||||
paths.length === candidate
|
||||
? undefined
|
||||
: moreProbes.current[candidate]?.getBoundingClientRect().width)
|
||||
setShownCount(fitProducedFiles(row.clientWidth, gap, chips, more))
|
||||
}
|
||||
measure()
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(row)
|
||||
for (const probe of [...chipProbes.current, ...moreProbes.current]) {
|
||||
if (probe !== null) observer.observe(probe)
|
||||
}
|
||||
return () => { observer.disconnect() }
|
||||
}, [limit, paths, t])
|
||||
|
||||
const visibleCount = Math.min(shownCount, limit)
|
||||
const shown = paths.slice(0, visibleCount)
|
||||
const hidden = paths.length - shown.length
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<span className={css.label}>{t('produced.label')}</span>
|
||||
{shown.map(path => (
|
||||
<button
|
||||
key={path}
|
||||
type="button"
|
||||
className={css.file}
|
||||
// The full path is the disambiguator when two turns produce files
|
||||
// that share a basename; the chip itself stays short.
|
||||
title={path}
|
||||
aria-label={t('produced.open', { name: path })}
|
||||
onClick={() => { openFile(path) }}
|
||||
>
|
||||
{basename(path)}
|
||||
<div ref={rowRef} className={css.row} data-produced-files-row>
|
||||
{shown.map(path => (
|
||||
<button
|
||||
key={path}
|
||||
type="button"
|
||||
className={css.file}
|
||||
// The full path is the disambiguator when two turns produce files
|
||||
// that share a basename; the chip itself stays short.
|
||||
title={path}
|
||||
aria-label={t('produced.open', { name: path })}
|
||||
onClick={() => { openFile(path) }}
|
||||
>
|
||||
{basename(path)}
|
||||
</button>
|
||||
))}
|
||||
{hidden > 0 && <span className={css.more}>{moreLabel(t, hidden)}</span>}
|
||||
</div>
|
||||
{hidden > 0 && canOpenPath && (
|
||||
<button type="button" className={css.showFolder} onClick={() => { openFile('.') }}>
|
||||
{t('produced.showInFolder')}
|
||||
</button>
|
||||
))}
|
||||
{hidden > 0 && <span className={css.more}>{t('produced.more', { count: String(hidden) })}</span>}
|
||||
)}
|
||||
<div className={css.measure} aria-hidden="true">
|
||||
{paths.slice(0, limit).map((path, index) => (
|
||||
<button
|
||||
key={path}
|
||||
ref={(node) => { chipProbes.current[index] = node }}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className={`${css.file} ${css.probe}`}
|
||||
>
|
||||
{basename(path)}
|
||||
</button>
|
||||
))}
|
||||
{Array.from({ length: limit + 1 }, (_, candidate) => {
|
||||
const remaining = paths.length - candidate
|
||||
if (remaining === 0) return null
|
||||
return (
|
||||
<span
|
||||
key={candidate}
|
||||
ref={(node) => { moreProbes.current[candidate] = node }}
|
||||
className={`${css.more} ${css.probe}`}
|
||||
>
|
||||
{moreLabel(t, remaining)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,10 +7,14 @@
|
||||
* composing this plugin out of cordis.yml removes both surfaces entirely;
|
||||
* the owning view renders an empty chain and inert prose at zero cost.
|
||||
*/
|
||||
import { createElement, useSyncExternalStore } from 'react'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { ProducedFiles } from './ProducedFiles.tsx'
|
||||
import {
|
||||
ProducedFiles, type ProducedFilesSeatProps,
|
||||
} from './ProducedFiles.tsx'
|
||||
import { en, NS, zh, type DeliverablesKey } from './locales.ts'
|
||||
import {
|
||||
deliverablesDefinition, producedFileMentions, selectProducedFiles,
|
||||
@@ -27,13 +31,24 @@ export { ProducedFiles, type ProducedFilesProps } from './ProducedFiles.tsx'
|
||||
export { producedForClosing } from './turn-deliverables.ts'
|
||||
|
||||
/** Required services for the tail-slot registration and its dictionaries. */
|
||||
export const inject = ['slots', 'locale', 'conversationEvents']
|
||||
export const inject = ['slots', 'locale', 'conversationEvents', 'connection']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the dictionaries and the turn-tail entry.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const ProducedFilesSeat = (props: ProducedFilesSeatProps): ReturnType<typeof createElement> => {
|
||||
const description = useSyncExternalStore(
|
||||
listener => connection.hostDescription.subscribe(listener),
|
||||
() => connection.hostDescription.getSnapshot(),
|
||||
)
|
||||
return createElement(ProducedFiles, {
|
||||
...props,
|
||||
canOpenPath: connection.isLoopback && description?.canOpenPath === true,
|
||||
})
|
||||
}
|
||||
ctx.conversationEvents.register(deliverablesDefinition)
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries')
|
||||
ctx.slots.inject(
|
||||
@@ -42,7 +57,7 @@ export function apply(ctx: ClientContext): void {
|
||||
name: 'conversation.chat.turnTail',
|
||||
select: selectProducedFiles,
|
||||
locale: NS,
|
||||
}, ProducedFiles),
|
||||
}, ProducedFilesSeat),
|
||||
)
|
||||
// The prose side of the same vocabulary: the chat view reaches this face
|
||||
// via ctx.get, so its absence — this plugin composed out — is the off state.
|
||||
|
||||
@@ -6,15 +6,19 @@ export const NS = 'deliverables'
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'produced.label': '产物',
|
||||
'produced.more': '还有 {count} 个',
|
||||
'produced.moreOne': '+ 1 个文件',
|
||||
'produced.more': '+ {count} 个文件',
|
||||
'produced.open': '打开 {name}',
|
||||
'produced.showInFolder': '在文件夹中显示',
|
||||
}
|
||||
|
||||
/** English dictionary (same key set). */
|
||||
export const en: Record<DeliverablesKey, string> = {
|
||||
'produced.label': 'Produced',
|
||||
'produced.more': '{count} more',
|
||||
'produced.moreOne': '+ 1 file',
|
||||
'produced.more': '+ {count} files',
|
||||
'produced.open': 'Open {name}',
|
||||
'produced.showInFolder': 'Show in folder',
|
||||
}
|
||||
|
||||
/** Union of this namespace's dictionary keys. */
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* (HMR safety) against the real SlotsService.
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import type { ComponentType } from 'react'
|
||||
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ConversationEventRegistry, ConversationNodeAssembler, SlotsService,
|
||||
@@ -19,7 +20,7 @@ import type {
|
||||
import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { ProducedFiles } from '../src/client/ProducedFiles.tsx'
|
||||
import { fitProducedFiles, ProducedFiles, type ProducedFilesSeatProps } from '../src/client/ProducedFiles.tsx'
|
||||
import {
|
||||
basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles,
|
||||
type DeliverablesTurnData,
|
||||
@@ -27,9 +28,20 @@ import {
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { apply as applyNode } from '../src/index.ts'
|
||||
import { apply as applyInvariant } from '../src/invariant.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
if (originalClientWidth === undefined) {
|
||||
delete (HTMLElement.prototype as { clientWidth?: number }).clientWidth
|
||||
} else {
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth)
|
||||
}
|
||||
})
|
||||
|
||||
class TestTurnDataStore implements ConversationLocationDataStore<ConversationTurnDataMap> {
|
||||
private readonly values = new Map<string, unknown>()
|
||||
@@ -269,21 +281,104 @@ describe('produced-file Turn data', () => {
|
||||
describe('ProducedFiles row', () => {
|
||||
const t = makeTranslate(zh)
|
||||
|
||||
it('renders capped chips with the full path reachable and opens one on click', () => {
|
||||
// Seven files: six chips plus an explicit remainder — the row bounds what
|
||||
// it shows and says so rather than dropping the rest silently.
|
||||
it('selects the largest prefix using the exact remainder width', () => {
|
||||
expect(fitProducedFiles(230, 8, [70, 60, 60], [55, 55, 55, 55])).toBe(2)
|
||||
expect(fitProducedFiles(145, 8, [70, 60, 60], [55, 55, 55, 55])).toBe(1)
|
||||
expect(fitProducedFiles(300, 8, [70, 60, 60], [55, 55, 55, 55])).toBe(3)
|
||||
// A zero-width lane is a pre-layout test/hidden state, not evidence that
|
||||
// every chip overflowed; keep the bounded initial prefix until measured.
|
||||
expect(fitProducedFiles(0, 8, [70, 60], [60, 50, undefined])).toBe(2)
|
||||
// Candidate-specific suffix widths matter at the 10 -> 9 digit boundary.
|
||||
expect(fitProducedFiles(126, 8, [60], [70, 50])).toBe(1)
|
||||
expect(fitProducedFiles(20, 8, [60], [70, 50])).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps one measured line, updates on resize, and opens a file or the workspace folder', () => {
|
||||
const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts']
|
||||
const openFile = vi.fn<(path: string) => void>()
|
||||
const view = render(<ProducedFiles matched={paths} openFile={openFile} t={t} />)
|
||||
let available = 226
|
||||
let resize: ResizeObserverCallback | undefined
|
||||
const disconnect = vi.fn()
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
constructor(callback: ResizeObserverCallback) { resize = callback }
|
||||
observe(): void {}
|
||||
disconnect(): void { disconnect() }
|
||||
})
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) { return this.hasAttribute('data-produced-files-row') ? available : 0 },
|
||||
})
|
||||
const rect = (width: number): DOMRect => ({
|
||||
x: 0, y: 0, width, height: 22, top: 0, right: width, bottom: 22, left: 0,
|
||||
toJSON: () => ({}),
|
||||
})
|
||||
const bounds = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockImplementation(function getProbeRect(this: HTMLElement) {
|
||||
if (this.closest('[aria-hidden="true"]') === null) return rect(0)
|
||||
if (this.tagName !== 'BUTTON') return rect(60)
|
||||
return rect(this.textContent === 'a.html' || this.textContent === 'b.css' ? 50 : 100)
|
||||
})
|
||||
|
||||
const view = render(
|
||||
<ProducedFiles matched={paths} openFile={openFile} canOpenPath t={t} />,
|
||||
)
|
||||
expect(view.getByText('产物')).toBeTruthy()
|
||||
// Chips carry the basename; the full path stays reachable as the title.
|
||||
const row = view.container.querySelector('[data-produced-files-row]')
|
||||
if (!(row instanceof HTMLElement)) throw new Error('produced row missing')
|
||||
// The third probe is 100px: two chips plus the remainder fit, three do not.
|
||||
expect(within(row).getAllByRole('button')).toHaveLength(2)
|
||||
expect(within(row).getByText('+ 5 个文件')).toBeTruthy()
|
||||
const chip = view.getByRole('button', { name: '打开 deep/a.html' })
|
||||
expect(chip.textContent).toBe('a.html')
|
||||
expect(chip.getAttribute('title')).toBe('deep/a.html')
|
||||
expect(view.queryByRole('button', { name: '打开 g.ts' })).toBeNull()
|
||||
expect(view.getByText('还有 1 个')).toBeTruthy()
|
||||
fireEvent.click(chip)
|
||||
expect(openFile).toHaveBeenCalledWith('deep/a.html')
|
||||
|
||||
const showFolder = view.getByRole('button', { name: '在文件夹中显示' })
|
||||
fireEvent.click(showFolder)
|
||||
expect(openFile).toHaveBeenLastCalledWith('.')
|
||||
|
||||
available = 150
|
||||
act(() => { resize?.([], {} as ResizeObserver) })
|
||||
expect(within(row).getAllByRole('button')).toHaveLength(1)
|
||||
expect(within(row).getByText('+ 6 个文件')).toBeTruthy()
|
||||
|
||||
view.unmount()
|
||||
expect(disconnect).toHaveBeenCalledOnce()
|
||||
bounds.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps the folder action absent without overflow or a local native opener', () => {
|
||||
const openFile = vi.fn<(path: string) => void>()
|
||||
const noOverflow = render(
|
||||
<ProducedFiles matched={['a.md']} openFile={openFile} canOpenPath t={t} />,
|
||||
)
|
||||
expect(noOverflow.queryByRole('button', { name: '在文件夹中显示' })).toBeNull()
|
||||
noOverflow.unmount()
|
||||
const headless = render(
|
||||
<ProducedFiles
|
||||
matched={['a.md', 'b.md', 'c.md', 'd.md', 'e.md', 'f.md', 'g.md']}
|
||||
openFile={openFile}
|
||||
canOpenPath={false}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
expect(headless.queryByRole('button', { name: '在文件夹中显示' })).toBeNull()
|
||||
})
|
||||
|
||||
it('uses singular English copy when exactly one file is hidden', () => {
|
||||
const view = render(
|
||||
<ProducedFiles
|
||||
matched={['a.md', 'b.md', 'c.md', 'd.md', 'e.md', 'f.md', 'g.md']}
|
||||
openFile={() => {}}
|
||||
canOpenPath={false}
|
||||
t={makeTranslate(en)}
|
||||
/>,
|
||||
)
|
||||
const row = view.container.querySelector('[data-produced-files-row]')
|
||||
if (!(row instanceof HTMLElement)) throw new Error('produced row missing')
|
||||
expect(within(row).getByText('+ 1 file')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -340,13 +435,56 @@ describe('plugin registration', () => {
|
||||
name: 'root',
|
||||
children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } },
|
||||
} as never, () => null)
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
const hostDescription = (canOpenPath: boolean) => ({
|
||||
version: 'test', cwd: '/workspace', attachedSessions: 1, canOpenPath,
|
||||
})
|
||||
let description: ReturnType<typeof hostDescription> | undefined = hostDescription(true)
|
||||
const descriptionListeners = new Set<() => void>()
|
||||
const connection = {
|
||||
api: { settings: {} },
|
||||
isLoopback: false,
|
||||
hostDescription: {
|
||||
getSnapshot: () => description,
|
||||
subscribe: (listener: () => void) => {
|
||||
descriptionListeners.add(listener)
|
||||
return () => { descriptionListeners.delete(listener) }
|
||||
},
|
||||
},
|
||||
}
|
||||
ctx.provide('connection', connection as never)
|
||||
await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await()
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(1)
|
||||
|
||||
// The native action needs both independent facts. A Host capability does
|
||||
// not authorize a remote page; loopback without a capable Host does not
|
||||
// render a dead action; reconnect/disconnect retracts the capability.
|
||||
const Entry = ctx.slots.entries('conversation.chat.turnTail')[0]!.component as ComponentType<ProducedFilesSeatProps>
|
||||
const entryProps: ProducedFilesSeatProps = {
|
||||
matched: ['a.md', 'b.md', 'c.md', 'd.md', 'e.md', 'f.md', 'g.md'],
|
||||
openFile: () => {},
|
||||
t: makeTranslate(zh),
|
||||
}
|
||||
const surface = render(<Entry {...entryProps} />)
|
||||
expect(surface.queryByRole('button', { name: '在文件夹中显示' })).toBeNull()
|
||||
connection.isLoopback = true
|
||||
description = hostDescription(false)
|
||||
surface.rerender(<Entry {...entryProps} />)
|
||||
expect(surface.queryByRole('button', { name: '在文件夹中显示' })).toBeNull()
|
||||
act(() => {
|
||||
description = hostDescription(true)
|
||||
for (const listener of descriptionListeners) listener()
|
||||
})
|
||||
expect(surface.getByRole('button', { name: '在文件夹中显示' })).toBeTruthy()
|
||||
act(() => {
|
||||
description = undefined
|
||||
for (const listener of descriptionListeners) listener()
|
||||
})
|
||||
expect(surface.queryByRole('button', { name: '在文件夹中显示' })).toBeNull()
|
||||
surface.unmount()
|
||||
|
||||
// The prose face is live while the plugin is: a produced turn yields a
|
||||
// resolver whose matches open through the owner-supplied opener.
|
||||
const opened: string[] = []
|
||||
|
||||
Reference in New Issue
Block a user