feat(web): a prose mention of a produced file opens it

The chatFileMentions service (provided by ui-deliverables beside its
turn-tail entry, reached via ctx.get) resolves inline-code tokens in the
closing message against the turn's produced locations: exact path or
unique basename links, ambiguity and unknowns stay inert. MarkdownText
gains the optional fileMentions seam — settled renders only, never
inside anchors.
This commit is contained in:
ZiyaZhang
2026-08-07 03:02:10 -07:00
parent 146c98c9bb
commit fe505e1e91
31 changed files with 584 additions and 43 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: b8b0ea2ef1cbc9b18b905fc08b41278f403ef043
README.zh.md: a16535b8a8d3625ca1cf90e88c6d9dca742d916b
README.md: d6695f155907e7d92b35556588687b3f95e55b88
README.zh.md: be360a5a1fbe8d904cedf104b28f64f1b0567d6b

View File

@@ -8,6 +8,8 @@ Produced-files feature owner: registers the deliverables row a finished turn end
`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).
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 renders as the same underlined opener the row's chips are, with the full path as its `title`, and 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).
## Model Experience
None, as the row is a pure client derivation over already-logged tool metadata and nothing here reaches a model request.
@@ -18,4 +20,4 @@ None; this package neither assembles nor sends provider requests.
## Known Limitations and Deferred Work
- **Prose mentions stay inert.** An inline-code file name in the closing message does not open the file yet; linking it to the same `locations` vocabulary is the stacked follow-up.
- **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.

View File

@@ -8,6 +8,8 @@
`ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个安静的标签、至多六枚 chip文本为文件名完整路径作为 `title`),超出上限则显示一个明确的剩余计数。每枚 chip 经由 owner 提供的 `openFile` 打开——与工具行相同的 Host 打开器chat 视图会把相对路径按会话 cwd 解析。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。
收尾正文承载同一份词表。本插件提供 chat 视图按收尾消息查询的 `chatFileMentions` service`producedFileMentions` 按精确路径解析行内代码 token或当 token 恰好是且仅是一条产出路径的 basename 时解析——两条路径共享的 basename 保持死文本而不猜测,因此提及链接永远不会打开错误的文件或 404。解析成功的提及渲染为与产物行 chip 相同的下划线 opener完整路径作为其 `title`;提及绝不会渲染在锚点内部或流式文本里。决策记录:[行内文件提及 Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md)。
## 模型体验
无。该行是对已记录工具元数据的纯客户端派生,这里没有任何内容进入模型请求。
@@ -18,4 +20,4 @@
## 已知限制与暂缓事项
- **正文提及仍是死文本。**收尾消息里以行内代码写出的文件名尚不能点击打开;把它接到同一份 `locations` 词表是 stacked 的后续工作
- **提及匹配只认精确路径或唯一 basename。**后缀式提及(`out/index.html` 写作 `index.html` 可解析;`deep/out/index.html` 写作 `out/index.html` 则不行)保持死文本;放宽匹配器等真实的收尾消息形态需要时再做

View File

@@ -6,18 +6,13 @@
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
/** Trailing path segment, the part that identifies the file at a glance. */
function basename(path: string): string {
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
return at === -1 ? path : path.slice(at + 1)
}
/** Matched paths plus the opener and locale seats needed to present them. */
export type ProducedFilesProps = Pick<TurnTailOwnerProps, 'openFile'> & {
matched: readonly string[]

View File

@@ -1,16 +1,18 @@
/**
* Deliverables plugin, browser half: registers the produced-files row into
* the chat view's turn-tail hole. All policy lives here — the derivation
* from the mutation tools' `locations`, the chip cap, and the copy — so
* composing this plugin out of cordis.yml removes the surface entirely; the
* owning view renders an empty hole at zero cost.
* the chat view's turn-tail chain, and provides the `chatFileMentions`
* service that links inline-code mentions of produced files in the closing
* prose. All policy lives here — the derivation from the mutation tools'
* `locations`, the mention matching, the chip cap, and the copy — so
* 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 type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/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 { en, NS, zh, type DeliverablesKey } from './locales.ts'
import { selectProducedFiles } from './turn-deliverables.ts'
import { producedFileMentions, producedForClosing, selectProducedFiles } from './turn-deliverables.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
@@ -39,4 +41,15 @@ export function apply(ctx: ClientContext): void {
locale: NS,
}, ProducedFiles),
)
// 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.
const t = ctx.locale.bind(NS)
const mentions: ChatFileMentions = {
forClosing(owner) {
const paths = producedForClosing(owner.nodes, owner.seq)
if (paths.length === 0) return undefined
return producedFileMentions(paths, owner.openFile, path => t('produced.open', { name: path }))
},
}
ctx.provide('chatFileMentions', mentions)
}

View File

@@ -4,6 +4,7 @@
* own follow-along `locations`, never the closing prose.
*/
import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
/**
@@ -88,3 +89,45 @@ export function selectProducedFiles(owner: TurnTailOwnerProps): readonly string[
const paths = producedForClosing(nodes, seq)
return paths.length === 0 ? null : paths
}
/**
* Trailing path segment, the part that identifies the file at a glance.
* @param path - Slash- or backslash-separated path.
* @returns The final segment, or the whole string when separator-free.
*/
export function basename(path: string): string {
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
return at === -1 ? path : path.slice(at + 1)
}
/**
* File-mention vocabulary over one turn's produced paths, for the closing
* message's prose: an inline-code token opens the file it names. A token
* resolves 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.
* @param paths - The turn's produced paths (tool order, already deduped).
* @param openFile - The chat view's file opener.
* @param label - Localizes the accessible open-label for a resolved path.
* @returns The resolver MarkdownText consumes; the full path rides `title`,
* the same disambiguator the row's chips carry.
*/
export function producedFileMentions(
paths: readonly string[],
openFile: (path: string) => void,
label: (path: string) => string,
): MarkdownFileMentions {
return {
resolve(value) {
const path = paths.includes(value) ? value : onlyPathWithBasename(paths, value)
if (path === undefined) return undefined
return { open: () => { openFile(path) }, label: label(path), title: path }
},
}
}
/** The single produced path whose basename is exactly `value`, else undefined. */
function onlyPathWithBasename(paths: readonly string[], value: string): string | undefined {
const matches = paths.filter(path => basename(path) === value)
return matches.length === 1 ? matches[0] : undefined
}

View File

@@ -13,9 +13,10 @@ import type {
AssistantMessageNode, ConversationNode, ToolResultNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client'
import type { ChatFileMentions } 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 { producedForClosing, selectProducedFiles } from '../src/client/turn-deliverables.ts'
import { basename, producedFileMentions, producedForClosing, selectProducedFiles } from '../src/client/turn-deliverables.ts'
import { apply, inject } from '../src/client/index.ts'
import { apply as applyNode } from '../src/index.ts'
import { apply as applyInvariant } from '../src/invariant.ts'
@@ -141,6 +142,33 @@ describe('ProducedFiles row', () => {
})
})
describe('producedFileMentions resolver', () => {
const label = (path: string) => `打开 ${path}`
it('resolves exact paths and unique basenames; ambiguity and unknowns stay unresolved', () => {
const opened: string[] = []
const resolver = producedFileMentions(
['out/index.html', 'a/style.css', 'b/style.css'],
(path) => { opened.push(path) },
label,
)
// Unique basename resolves to its full path; the full path rides title.
const byBasename = resolver.resolve('index.html')
expect(byBasename?.label).toBe('打开 out/index.html')
expect(byBasename?.title).toBe('out/index.html')
byBasename?.open()
expect(opened).toEqual(['out/index.html'])
// An exact path resolves even when its basename is ambiguous.
const exact = resolver.resolve('a/style.css')
expect(exact?.title).toBe('a/style.css')
// A basename two paths share stays unresolved rather than guessing,
// and so does a token naming nothing the turn wrote.
expect(resolver.resolve('style.css')).toBeUndefined()
expect(resolver.resolve('notes.md')).toBeUndefined()
expect(basename('a\\b\\c.txt')).toBe('c.txt')
})
})
describe('package shells', () => {
it('the node half mounts inert and the invariant companion registers ownership', async () => {
// The node half is deliberately inert; mounting it must simply not throw.
@@ -172,7 +200,24 @@ describe('plugin registration', () => {
await fiber.await()
expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(1)
// 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[] = []
const owner = {
nodes: [user(1, 'go'), wrote(2, 'w', 'site/report.html'), assistant(3, 'done', 1)],
seq: 3,
openFile: (path: string) => { opened.push(path) },
}
const service = (ctx as unknown as { get(name: string): ChatFileMentions | undefined }).get('chatFileMentions')
const mentions = service?.forClosing(owner)
mentions?.resolve('report.html')?.open()
expect(opened).toEqual(['site/report.html'])
// A turn that produced nothing yields no vocabulary at all.
expect(service?.forClosing({ ...owner, nodes: [user(1, 'hi'), assistant(2, 'ok', 1)], seq: 2 })).toBeUndefined()
await fiber.dispose()
expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0)
// Fiber teardown retracts the service: the consumer's ctx.get sees the off state.
expect((ctx as unknown as { get(name: string): unknown }).get('chatFileMentions')).toBeUndefined()
})
})