mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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-conversation/README.md
|
||||
README.md: d708621dbeb99615b0864e6977c8d2f387ae5265
|
||||
README.zh.md: e2cf9c3b32c86c2da39f09e4804c2ac69a3569f7
|
||||
README.md: 6182fa1281eec50f8898634cb466291b6ef1f5ff
|
||||
README.zh.md: 8b110ec7713dcda5972d0ea0d9a8042301906d99
|
||||
|
||||
@@ -50,7 +50,7 @@ The chat stats line takes its token accounting from the generic token-meter `tok
|
||||
|
||||
`src/client/` is organized by domain. `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations and composed props, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` directories import contract files and never each other. `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components and the store factory stay internal and reach the page through apply's slot registrations.
|
||||
|
||||
A finished turn ends with a turn-tail hole: the chat view renders the `conversation.chat.turnTail` list slot between the closing assistant's body and its IconActions, once per turn at the seq `assistantActionsSeqs` elects, dispatching `TurnTailOwnerProps` (the snapshot nodes, the closing seq, and the tool rows' `openFile`). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' `locations`, the chip cap, the copy — lives in `@deepseek-ai/dsh-client-ui-deliverables`, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost.
|
||||
A finished turn ends with a turn-tail hole: the chat view renders the `conversation.chat.turnTail` list slot between the closing assistant's body and its IconActions, once per turn at the seq `assistantActionsSeqs` elects, dispatching `TurnTailOwnerProps` (the snapshot nodes, the closing seq, and the tool rows' `openFile`). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' `locations`, the chip cap, the copy — lives in `@deepseek-ai/dsh-client-ui-deliverables`, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. The closing prose participates through the same off switch: the chat view asks the optional `chatFileMentions` service (ctx.get; provided by the same plugin) for a closing message's inline-code vocabulary and threads the result into MarkdownText's `fileMentions` seam — an absent service leaves the prose inert.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
|
||||
|
||||
`src/client/` 按领域组织。`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明与组合后的 props、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/` 目录只导入 contract 文件,彼此之间从不互相导入。`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件与 store factory 保持内部,经 apply 的 slot 注册抵达页面。
|
||||
|
||||
完成的一轮以一个 turn-tail 空位收尾:chat 视图在收尾 assistant 正文与其 IconActions 之间渲染 `conversation.chat.turnTail` list slot,每轮一次、位于 `assistantActionsSeqs` 选出的 seq,派发 `TurnTailOwnerProps`(快照节点、收尾 seq,以及工具行的 `openFile`)。本包只拥有空位;填充它的产物行——从改写工具 `locations` 的派生、chip 上限、文案——都在 `@deepseek-ai/dsh-client-ui-deliverables` 里,因此把那个插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。
|
||||
完成的一轮以一个 turn-tail 空位收尾:chat 视图在收尾 assistant 正文与其 IconActions 之间渲染 `conversation.chat.turnTail` list slot,每轮一次、位于 `assistantActionsSeqs` 选出的 seq,派发 `TurnTailOwnerProps`(快照节点、收尾 seq,以及工具行的 `openFile`)。本包只拥有空位;填充它的产物行——从改写工具 `locations` 的派生、chip 上限、文案——都在 `@deepseek-ai/dsh-client-ui-deliverables` 里,因此把那个插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。收尾正文经由同一个开关参与其中:chat 视图向可选的 `chatFileMentions` service(ctx.get;由同一插件提供)索取收尾消息的行内代码词表,并把结果接进 MarkdownText 的 `fileMentions` seam——service 缺席时正文保持死文本。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -313,6 +313,7 @@ export function apply(ctx: Context): void {
|
||||
actions.select(target)
|
||||
layout.openDetails()
|
||||
},
|
||||
fileMentions: owner => ctx.get('chatFileMentions')?.forClosing(owner),
|
||||
openFile: (path) => {
|
||||
const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd
|
||||
void workspaces.openPath(resolveToolPath(cwd, path)).catch(() => {
|
||||
|
||||
@@ -15,7 +15,8 @@ import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
IconThinkOutline14, JsonBlock, MarkdownText,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps, TurnTailOwnerProps } from '../contract/slots.ts'
|
||||
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps, ChatViewInjected, TurnTailOwnerProps } from '../contract/slots.ts'
|
||||
import { hasContentText } from './chat-flow.ts'
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
@@ -43,6 +44,8 @@ export interface AssistantMarkdownProps {
|
||||
onFork?: ((seq: number) => void) | undefined
|
||||
/** Turn-tail slot dispatch share and owner currency; omitted for a mid-turn assistant. */
|
||||
turnTail?: (Pick<PropsRenderSlots<'conversation.chat.turnTail'>, 'renderSlotChain'> & { owner: TurnTailOwnerProps }) | undefined
|
||||
/** Prose file-mention factory (the injected face); omitted wherever `turnTail` is. */
|
||||
fileMentions?: ChatViewInjected['fileMentions'] | undefined
|
||||
/** The message is not the transcript tail of a completed turn. */
|
||||
forkUnavailable?: boolean | undefined
|
||||
/** The owning view's locale seat, passed down as a plain prop. */
|
||||
@@ -86,11 +89,25 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass
|
||||
}
|
||||
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail, t,
|
||||
blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail,
|
||||
fileMentions, t,
|
||||
}: AssistantMarkdownProps) {
|
||||
// Stable per locale revision (t identity changes on switch): a fresh object
|
||||
// per render would rebuild MarkdownText's component table every chunk.
|
||||
const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t])
|
||||
// Mention vocabulary for the closing prose. Keyed on the anchor seq, not the
|
||||
// growing transcript: a settled turn's produced files are final, and a
|
||||
// fresh identity per append would discard MarkdownText's cached parse for
|
||||
// every settled closing message on every stream chunk. The window-prepend
|
||||
// edge (a mid-turn window start later gaining earlier same-turn writes)
|
||||
// leaves a mention unlinked until remount — never a wrong link.
|
||||
const owner = turnTail?.owner
|
||||
const mentions: MarkdownFileMentions | undefined = useMemo(
|
||||
() => (owner === undefined ? undefined : fileMentions?.(owner)),
|
||||
// Deliberately not `owner`: its identity changes per append while the
|
||||
// seq-addressed vocabulary it yields does not.
|
||||
[fileMentions, owner?.seq],
|
||||
)
|
||||
const last = blocks.length - 1
|
||||
// Tool-call heads render as tool rows in the chat view's grouping pass, so
|
||||
// a node that is only those heads (or empty) would paint an empty root
|
||||
@@ -107,7 +124,13 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
{blocks.map((block, i) => {
|
||||
switch (block.kind) {
|
||||
case 'text': return (
|
||||
<MarkdownText key={i} text={block.text} streaming={streaming} codeLabels={codeLabels} />
|
||||
<MarkdownText
|
||||
key={i}
|
||||
text={block.text}
|
||||
streaming={streaming}
|
||||
codeLabels={codeLabels}
|
||||
fileMentions={mentions}
|
||||
/>
|
||||
)
|
||||
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} t={t} />
|
||||
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
|
||||
|
||||
@@ -335,7 +335,8 @@ function StreamingTail({ useSession, t }: {
|
||||
* render through the declared keyed hole's renderSlot share).
|
||||
*/
|
||||
export function ChatView({
|
||||
useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
|
||||
useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt,
|
||||
fileMentions, t,
|
||||
}: ChatViewSlotProps) {
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const turnTimings = useSession(s => s.turnTimings)
|
||||
@@ -603,6 +604,7 @@ export function ChatView({
|
||||
turnTail={actionSeqs.has(node.seq)
|
||||
? { renderSlotChain, owner: { nodes, seq: node.seq, openFile } }
|
||||
: undefined}
|
||||
fileMentions={actionSeqs.has(node.seq) ? fileMentions : undefined}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { CommandNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
@@ -158,6 +159,30 @@ export interface ConvViewOwnerProps {
|
||||
onInspectDone?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional prose file-mention provider, consumed via `ctx.get('chatFileMentions')`
|
||||
* (optional-service convention): the chat view asks it for a closing message's
|
||||
* inline-code vocabulary and threads the result into MarkdownText. Absent
|
||||
* service — the providing plugin composed out of cordis.yml — turns the
|
||||
* surface off; the prose renders inert code.
|
||||
*/
|
||||
export interface ChatFileMentions {
|
||||
/**
|
||||
* Mention vocabulary for the closing message the owner currency names.
|
||||
* @param owner - Turn-tail owner currency (nodes, closing seq, opener).
|
||||
* @returns The resolver MarkdownText consumes, or undefined when the turn
|
||||
* produced nothing worth linking.
|
||||
*/
|
||||
forClosing(owner: TurnTailOwnerProps): MarkdownFileMentions | undefined
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Prose file-mention provider (ui-deliverables); reach via ctx.get — optional. */
|
||||
chatFileMentions: ChatFileMentions
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner currency of the chat view's turn-tail hole: the finalized snapshot
|
||||
* and the closing assistant's anchor. Registrants derive their own facts
|
||||
@@ -502,6 +527,13 @@ export interface ChatViewInjected {
|
||||
}
|
||||
/** Fork through the completed turn ending at the eligible message `seq`, then open the child. */
|
||||
forkAt: (seq: number) => void
|
||||
/**
|
||||
* Prose file-mention vocabulary for one closing message, from the optional
|
||||
* {@link ChatFileMentions} service (resolved lazily per call, so composing
|
||||
* the provider in or out takes effect live). Undefined when the service is
|
||||
* absent or the turn produced nothing worth linking.
|
||||
*/
|
||||
fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
|
||||
}
|
||||
|
||||
/** Full chat-view component props: runtime & the declared toolview/commandview holes' render share & store & injected & locale seat. */
|
||||
|
||||
@@ -13,6 +13,7 @@ export type {
|
||||
export type { ToolCallBlock } from './contract/tool-call-model.ts'
|
||||
export type { ConversationKey } from './locales.ts'
|
||||
export type {
|
||||
ChatFileMentions,
|
||||
ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected,
|
||||
ComposerChainProps, ConversationInjected,
|
||||
ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps,
|
||||
|
||||
@@ -154,6 +154,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
inspectCall,
|
||||
chatScroll,
|
||||
forkAt,
|
||||
// Absent-service default; mention tests override with a real resolver.
|
||||
fileMentions: () => undefined,
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
t: makeTranslate(zh, commonZh),
|
||||
}
|
||||
@@ -248,6 +250,45 @@ describe('chat-flow derivation', () => {
|
||||
expect([...assistantActionsSeqs(nodes, new Map([[1, 5]]))]).toEqual([5])
|
||||
})
|
||||
|
||||
it('threads the injected file-mention vocabulary into the closing prose only', () => {
|
||||
const wrote = (seq: number, callId: string, path: string): ToolResultNode => ({
|
||||
...toolResult(seq, callId, 'write'),
|
||||
callView: {
|
||||
card: 'diff', title: 'Write', diffs: [{ path, oldText: null, newText: 'x' }], locations: [{ path }],
|
||||
},
|
||||
})
|
||||
const h = makeHarness({
|
||||
nodes: [
|
||||
user(1, 'build it'),
|
||||
assistant(2, 'writing `report.html` now', 1),
|
||||
wrote(3, 'w', 'site/report.html'),
|
||||
assistant(4, 'Wrote `report.html`; `notes.md` untouched.', 1),
|
||||
],
|
||||
turnEnds: new Map([[1, 4]]),
|
||||
})
|
||||
// Stub provider mirroring the real service: only produced files resolve.
|
||||
h.props.fileMentions = owner => ({
|
||||
resolve: (value) => {
|
||||
if (value !== 'report.html') return undefined
|
||||
return {
|
||||
open: () => { h.openFile(`for-seq-${String(owner.seq)}/site/report.html`) },
|
||||
label: '打开 site/report.html',
|
||||
title: 'site/report.html',
|
||||
}
|
||||
},
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// Exactly one live mention: the closing message links, the mid-turn
|
||||
// narration stays inert code, and the unknown file resolves to nothing.
|
||||
const mentions = view.container.querySelectorAll('code button')
|
||||
expect(mentions).toHaveLength(1)
|
||||
const mention = view.getByRole('button', { name: '打开 site/report.html' })
|
||||
expect(mention.getAttribute('title')).toBe('site/report.html')
|
||||
fireEvent.click(mention)
|
||||
// The vocabulary was built from the closing message's own owner currency.
|
||||
expect(h.openFile).toHaveBeenCalledWith('for-seq-4/site/report.html')
|
||||
})
|
||||
|
||||
it('runningTurnStartTime selects the latest turn/start without a turn/end', () => {
|
||||
expect(runningTurnStartTime(new Map([
|
||||
[1, { startTime: 1_000, endTime: 5_000 }],
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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-primitives/README.md
|
||||
README.md: 7571cb48424b650a1aaa5222b33a3ee14faa69b4
|
||||
README.zh.md: fa0c3f24023ec8c1eb77553bfe191801b6698687
|
||||
README.md: a5f35c9a41008921c6a1e3a4de3fd3f38af926c0
|
||||
README.zh.md: 54c70ed29e73e196a9be60ddb794c12187dbe0c5
|
||||
|
||||
@@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
|
||||
|
||||
## Markdown rendering
|
||||
|
||||
`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
|
||||
`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
|
||||
|
||||
## Terminal output
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
## Markdown 渲染
|
||||
|
||||
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性契约](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
|
||||
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有视图把命名真实文件的行内代码变成链接:token 保留代码样式并获得接到所解析 opener 的按钮,带解析器给出的无障碍标签与完整路径 `title`。渲染器绝不猜测什么长得像路径——未解析的 token 保持原样,提及只作用于已定稿的渲染(流式缓存不能烘进可能过期的 handler),锚点内部的 token 也保持原样,因为按钮不能嵌套在链接里。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性契约](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
|
||||
|
||||
## 终端输出
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export { CodeBlock } from './markdown/CodeBlock.tsx'
|
||||
export type { CodeBlockProps } from './markdown/CodeBlock.tsx'
|
||||
export { JsonBlock } from './markdown/JsonBlock.tsx'
|
||||
export { MarkdownText } from './markdown/MarkdownText.tsx'
|
||||
export type { MarkdownCodeLabels } from './markdown/MarkdownText.tsx'
|
||||
export type { MarkdownCodeLabels, MarkdownFileMentions } from './markdown/MarkdownText.tsx'
|
||||
export { MessageText } from './markdown/MessageText.tsx'
|
||||
export { extractMarkdownPlainText } from './markdown/plain-text.ts'
|
||||
export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts'
|
||||
|
||||
@@ -241,3 +241,23 @@
|
||||
background: var(--dsw-alias-bg-base);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Inline file mention: a real file named in prose is the same affordance as a
|
||||
tool row's path link, so it reads the same — underlined at rest. */
|
||||
.fileMention {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--dsw-alias-label-quaternary);
|
||||
text-underline-offset: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fileMention:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
|
||||
@@ -19,20 +19,25 @@ import {
|
||||
collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection,
|
||||
wrapBlockChildren,
|
||||
} from './render.tsx'
|
||||
import type { MarkdownCodeLabels, MarkdownRenderContext, ReferenceTargets } from './render.tsx'
|
||||
import type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownRenderContext, ReferenceTargets } from './render.tsx'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import css from './MarkdownText.module.css'
|
||||
|
||||
export type { MarkdownCodeLabels } from './render.tsx'
|
||||
export type { MarkdownCodeLabels, MarkdownFileMentions } from './render.tsx'
|
||||
|
||||
/** One settled full render: parse with math, resolve references, append the footnote section. */
|
||||
function renderSettled(text: string, codeLabels: MarkdownCodeLabels | undefined): ReactNode[] {
|
||||
function renderSettled(
|
||||
text: string,
|
||||
codeLabels: MarkdownCodeLabels | undefined,
|
||||
fileMentions: MarkdownFileMentions | undefined,
|
||||
): ReactNode[] {
|
||||
const root = parseGfmWithMath(text)
|
||||
const targets = createReferenceTargets()
|
||||
collectReferenceTargets(root.children, targets)
|
||||
const context: MarkdownRenderContext = {
|
||||
streaming: false,
|
||||
codeLabels,
|
||||
fileMentions,
|
||||
targets,
|
||||
footnoteOrder: [],
|
||||
footnoteCounts: new Map(),
|
||||
@@ -96,6 +101,7 @@ class StreamingRenderer {
|
||||
const frozenContext: MarkdownRenderContext = {
|
||||
streaming: true,
|
||||
codeLabels: this.codeLabels,
|
||||
fileMentions: undefined,
|
||||
targets: frameTargets,
|
||||
footnoteOrder: this.frozenFootnoteOrder,
|
||||
footnoteCounts: this.frozenFootnoteCounts,
|
||||
@@ -113,6 +119,7 @@ class StreamingRenderer {
|
||||
const tailContext: MarkdownRenderContext = {
|
||||
streaming: true,
|
||||
codeLabels: this.codeLabels,
|
||||
fileMentions: undefined,
|
||||
targets: frameTargets,
|
||||
footnoteOrder: [...this.frozenFootnoteOrder],
|
||||
footnoteCounts: new Map(this.frozenFootnoteCounts),
|
||||
@@ -137,28 +144,33 @@ class StreamingRenderer {
|
||||
* the finalize swap) and parses incrementally across chunks; `codeLabels`
|
||||
* forwards localized copy-button labels to fence CodeBlocks — pass a
|
||||
* reference-stable object (memoized per locale revision), because a new
|
||||
* identity discards the streaming render cache mid-message.
|
||||
* identity discards the streaming render cache mid-message. `fileMentions`
|
||||
* links inline-code tokens its resolver recognizes as real files; this is
|
||||
* the single streaming gate — it applies to settled renders only, because a
|
||||
* streaming message's vocabulary is not final and frozen cached elements
|
||||
* must not bake in handlers that could go stale.
|
||||
* @returns A GFM document with TeX math rendered through KaTeX; raw HTML,
|
||||
* relative links, and unsafe protocols are disabled, while absolute HTTP(S)
|
||||
* images render directly.
|
||||
*/
|
||||
export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels }: {
|
||||
export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels, fileMentions }: {
|
||||
text: string
|
||||
streaming?: boolean
|
||||
codeLabels?: MarkdownCodeLabels | undefined
|
||||
fileMentions?: MarkdownFileMentions | undefined
|
||||
}) {
|
||||
const streamRef = useRef<StreamingRenderer | null>(null)
|
||||
const streamLabelsRef = useRef<MarkdownCodeLabels | undefined>(codeLabels)
|
||||
const children = useMemo(() => {
|
||||
if (!streaming) {
|
||||
streamRef.current = null
|
||||
return renderSettled(text, codeLabels)
|
||||
return renderSettled(text, codeLabels, fileMentions)
|
||||
}
|
||||
if (streamRef.current === null || streamLabelsRef.current !== codeLabels) {
|
||||
streamRef.current = new StreamingRenderer(codeLabels)
|
||||
streamLabelsRef.current = codeLabels
|
||||
}
|
||||
return streamRef.current.render(text)
|
||||
}, [text, streaming, codeLabels])
|
||||
}, [text, streaming, codeLabels, fileMentions])
|
||||
return <div className={css.markdown}>{children}</div>
|
||||
})
|
||||
|
||||
@@ -99,6 +99,21 @@ export function collectReferenceTargets(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* File-mention affordance for inline code: the owner resolves an authored
|
||||
* token to the file it names, using its own vocabulary of real files — the
|
||||
* renderer never guesses at what looks like a path.
|
||||
*/
|
||||
export interface MarkdownFileMentions {
|
||||
/**
|
||||
* Resolve one inline-code token.
|
||||
* @param value - The authored token, exactly as written.
|
||||
* @returns The opener with its accessible label and full-path title, or
|
||||
* undefined when the token names no known file — it then stays inert code.
|
||||
*/
|
||||
resolve(value: string): { open: () => void; label: string; title: string } | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* One render pass's state: immutable options and targets plus the footnote
|
||||
* numbering accumulated in document order while references render.
|
||||
@@ -108,6 +123,10 @@ export interface MarkdownRenderContext {
|
||||
readonly streaming: boolean
|
||||
/** Localized fence copy-button labels. */
|
||||
readonly codeLabels: MarkdownCodeLabels | undefined
|
||||
/** Inline-code file mentions; absent wherever no opener vocabulary exists. */
|
||||
readonly fileMentions: MarkdownFileMentions | undefined
|
||||
/** Inside an anchor's children: interactive mentions must not nest there. */
|
||||
readonly inLink?: boolean
|
||||
/** Reference targets visible to this pass. */
|
||||
readonly targets: ReferenceTargets
|
||||
/** Footnote identifiers in first-reference order; a footnote's number is its 1-based index here. */
|
||||
@@ -217,7 +236,27 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte
|
||||
// authored text, not a parsed destination, so no normalizeUri: port,
|
||||
// path, and query render unchanged.
|
||||
const href = inlineCodeHttpUrl(value)
|
||||
return <code key={key}>{href === undefined ? value : renderSafeLink(href, [value], 'link')}</code>
|
||||
if (href !== undefined) return <code key={key}>{renderSafeLink(href, [value], 'link')}</code>
|
||||
// A token the owner's file-mention vocabulary recognizes opens that
|
||||
// file; the resolver, not this renderer, decides what names a file.
|
||||
// Inside an anchor the token stays inert — a button cannot nest there.
|
||||
const mention = context.inLink === true ? undefined : context.fileMentions?.resolve(value)
|
||||
if (mention !== undefined) {
|
||||
return (
|
||||
<code key={key}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.fileMention}
|
||||
title={mention.title}
|
||||
aria-label={mention.label}
|
||||
onClick={mention.open}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
</code>
|
||||
)
|
||||
}
|
||||
return <code key={key}>{value}</code>
|
||||
}
|
||||
case 'html':
|
||||
// No HTML parser enters the pipeline: raw HTML stays literal text.
|
||||
@@ -236,7 +275,7 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte
|
||||
case 'table':
|
||||
return renderTable(node, key, context)
|
||||
case 'link':
|
||||
return renderAnchor(node.url, renderChildren(node.children, context), key)
|
||||
return renderAnchor(node.url, renderChildren(node.children, { ...context, inLink: true }), key)
|
||||
case 'linkReference':
|
||||
return renderLinkReference(node, key, context)
|
||||
case 'image':
|
||||
@@ -460,14 +499,14 @@ function renderLinkReference(
|
||||
context: MarkdownRenderContext,
|
||||
): ReactNode {
|
||||
const definition = context.targets.definitions.get(node.identifier.toUpperCase())
|
||||
const children = renderChildren(node.children, context)
|
||||
if (definition === undefined) {
|
||||
// The grammar only emits references whose definitions exist somewhere in
|
||||
// the same parse, but incremental segments and hand-built trees may still
|
||||
// present unresolved ones: revert to the bracketed source text.
|
||||
return <Fragment key={key}>{'['}{children}{referenceSuffix(node)}</Fragment>
|
||||
// present unresolved ones: revert to the bracketed source text — which is
|
||||
// not an anchor, so mentions inside it stay live.
|
||||
return <Fragment key={key}>{'['}{renderChildren(node.children, context)}{referenceSuffix(node)}</Fragment>
|
||||
}
|
||||
return renderAnchor(definition.url, children, key)
|
||||
return renderAnchor(definition.url, renderChildren(node.children, { ...context, inLink: true }), key)
|
||||
}
|
||||
|
||||
function renderImageReference(
|
||||
|
||||
@@ -20,6 +20,7 @@ function makeContext(): MarkdownRenderContext {
|
||||
return {
|
||||
streaming: false,
|
||||
codeLabels: undefined,
|
||||
fileMentions: undefined,
|
||||
targets: createReferenceTargets(),
|
||||
footnoteOrder: [],
|
||||
footnoteCounts: new Map(),
|
||||
|
||||
@@ -148,6 +148,49 @@ describe('MarkdownText', () => {
|
||||
expect(container.querySelector('pre code a')).toBeNull()
|
||||
})
|
||||
|
||||
it('links inline code through the file-mention resolver: URL first, settled only, never inside links', () => {
|
||||
const opened: string[] = []
|
||||
const fileMentions = {
|
||||
resolve: (value: string) => value === 'index.html' || value === 'out/index.html'
|
||||
? { open: () => { opened.push(value) }, label: 'Open out/index.html', title: 'out/index.html' }
|
||||
: undefined,
|
||||
}
|
||||
const source = [
|
||||
'`index.html`',
|
||||
'`other.css`',
|
||||
'`https://example.com/`',
|
||||
// Inside an anchor the mention stays inert code: a button cannot nest there.
|
||||
'[see `out/index.html`](https://example.com/doc)',
|
||||
'[ref `out/index.html`][target]',
|
||||
'[target]: https://example.com/ref',
|
||||
'```',
|
||||
'index.html',
|
||||
'```',
|
||||
].join('\n\n')
|
||||
const { container } = render(<MarkdownText text={source} fileMentions={fileMentions} />)
|
||||
|
||||
const mention = screen.getByRole('button', { name: 'Open out/index.html' })
|
||||
expect(mention.closest('code')).not.toBeNull()
|
||||
// The full path rides title, the same disambiguator the row's chips carry.
|
||||
expect(mention.getAttribute('title')).toBe('out/index.html')
|
||||
fireEvent.click(mention)
|
||||
expect(opened).toEqual(['index.html'])
|
||||
// Exactly one live mention: the two inside anchors declined, and an
|
||||
// unresolved token plus fenced code stay inert.
|
||||
expect(container.querySelectorAll('code button')).toHaveLength(1)
|
||||
expect(container.querySelectorAll('a code button, a button')).toHaveLength(0)
|
||||
expect(screen.getByText('other.css').closest('button')).toBeNull()
|
||||
// URL promotion wins before the resolver sees a token.
|
||||
expect(screen.getByText('https://example.com/').closest('a')).not.toBeNull()
|
||||
|
||||
// Streaming renders keep mentions off — the one gate lives here: cached
|
||||
// frozen elements must not bake in handlers that could go stale.
|
||||
const streamed = render(
|
||||
<MarkdownText text={'`index.html`\n\nmore\n\n'} streaming fileMentions={fileMentions} />,
|
||||
)
|
||||
expect(streamed.container.querySelector('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('exposes the CJK strong syntax as a micromark extension needing CommonMark attention markers', () => {
|
||||
const extension = cjkFriendlyStrong()
|
||||
expect(cjkFriendlyStrong()).toBe(extension)
|
||||
|
||||
Reference in New Issue
Block a user