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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` 则不行)保持死文本;放宽匹配器等真实的收尾消息形态需要时再做。
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user