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 files changed
+584 -43

No files matched your search

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