feat(web): make produced-file overflow discoverable

This commit is contained in:
ZiyaZhang
2026-08-10 08:08:24 -07:00
parent a40155ad23
commit ee1a88c9f1
40 changed files with 748 additions and 122 deletions

View File

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

View File

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

View File

@@ -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 桌面也必须这样配置。识别操作者实际可见的桌面仍属于部署策略。

View File

@@ -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:^",

View File

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

View File

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

View File

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

View File

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

View File

@@ -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[] = []